floe-python 0.8.0__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.
- floe/__init__.py +327 -0
- floe/__main__.py +5 -0
- floe/_cli.py +302 -0
- floe/_cli_output.py +187 -0
- floe/add_entity.py +516 -0
- floe/checks/cast.py +160 -0
- floe/checks/mismatch.py +137 -0
- floe/checks/normalize.py +118 -0
- floe/checks/rows.py +116 -0
- floe/checks/unique.py +247 -0
- floe/config/__init__.py +0 -0
- floe/config/entity.py +479 -0
- floe/config/include.py +122 -0
- floe/config/parse.py +140 -0
- floe/config/profile.py +381 -0
- floe/config/project.py +404 -0
- floe/config/storage.py +179 -0
- floe/config/types.py +195 -0
- floe/config/validate.py +803 -0
- floe/config/vars.py +127 -0
- floe/config/yaml.py +263 -0
- floe/errors.py +13 -0
- floe/glob.py +300 -0
- floe/iceberg/catalog.py +345 -0
- floe/iceberg/compact.py +139 -0
- floe/iceberg/merge.py +297 -0
- floe/iceberg/sink.py +208 -0
- floe/iceberg/unique.py +61 -0
- floe/iceberg/write.py +270 -0
- floe/lineage/events.py +163 -0
- floe/lineage/observer.py +261 -0
- floe/maintain.py +134 -0
- floe/manifest.py +503 -0
- floe/manifest_run.py +299 -0
- floe/pii.py +134 -0
- floe/py.typed +0 -0
- floe/read/__init__.py +0 -0
- floe/read/avro.py +40 -0
- floe/read/csv.py +138 -0
- floe/read/json.py +236 -0
- floe/read/orc.py +42 -0
- floe/read/parquet.py +43 -0
- floe/read/xlsx.py +75 -0
- floe/read/xml.py +227 -0
- floe/report/entity.py +245 -0
- floe/report/summary.py +114 -0
- floe/run/__init__.py +175 -0
- floe/run/entity.py +963 -0
- floe/run/events.py +42 -0
- floe/state.py +311 -0
- floe/storage/__init__.py +0 -0
- floe/storage/archive.py +31 -0
- floe/storage/inputs.py +302 -0
- floe/storage/local.py +175 -0
- floe/storage/partitioning.py +116 -0
- floe/storage/paths.py +69 -0
- floe/storage/resolve.py +55 -0
- floe/storage/s3.py +182 -0
- floe/storage/types.py +33 -0
- floe/write/__init__.py +0 -0
- floe/write/outputs.py +121 -0
- floe_python-0.8.0.dist-info/METADATA +15 -0
- floe_python-0.8.0.dist-info/RECORD +66 -0
- floe_python-0.8.0.dist-info/WHEEL +4 -0
- floe_python-0.8.0.dist-info/entry_points.txt +2 -0
- floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/run/entity.py
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
"""One entity run: inputs, per-file checks, split, outputs, `run.json` (Rust: `run/entity/*.rs`, local and S3 storage)."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import tempfile
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from functools import partial
|
|
12
|
+
from itertools import chain
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import polars as pl
|
|
17
|
+
from pyiceberg.table import Table
|
|
18
|
+
|
|
19
|
+
from floe.checks.cast import CAST_ERROR_MESSAGE, cast_column
|
|
20
|
+
from floe.checks.mismatch import (
|
|
21
|
+
MismatchOutcome,
|
|
22
|
+
apply_mismatch,
|
|
23
|
+
declared_names,
|
|
24
|
+
json_input_columns,
|
|
25
|
+
plan_mismatch,
|
|
26
|
+
)
|
|
27
|
+
from floe.checks.normalize import (
|
|
28
|
+
normalize_columns,
|
|
29
|
+
normalize_name,
|
|
30
|
+
output_column_mapping,
|
|
31
|
+
resolve_strategy,
|
|
32
|
+
source_names,
|
|
33
|
+
)
|
|
34
|
+
from floe.checks.rows import (
|
|
35
|
+
ERRORS,
|
|
36
|
+
ROW_INDEX,
|
|
37
|
+
Formatter,
|
|
38
|
+
RowCheck,
|
|
39
|
+
not_null_check,
|
|
40
|
+
split_rows,
|
|
41
|
+
)
|
|
42
|
+
from floe.checks.unique import UniqueResult, UniqueTracker, resolve_unique_keys
|
|
43
|
+
from floe.config.storage import CatalogsConfig, StorageDefinition, StoragesConfig
|
|
44
|
+
from floe.config.types import EntityConfig, with_partition_columns
|
|
45
|
+
from floe.errors import FloeConfigError, FloeIoError, FloeRunError
|
|
46
|
+
from floe.iceberg.sink import (
|
|
47
|
+
existing_table,
|
|
48
|
+
input_hash,
|
|
49
|
+
resolve_target,
|
|
50
|
+
write_iceberg,
|
|
51
|
+
)
|
|
52
|
+
from floe.iceberg.unique import seed_from_table
|
|
53
|
+
from floe.iceberg.write import find_committed
|
|
54
|
+
from floe.pii import apply_pii_masking
|
|
55
|
+
from floe.read.avro import read_avro
|
|
56
|
+
from floe.read.csv import RAGGED_MESSAGE, read_csv
|
|
57
|
+
from floe.read.json import read_json
|
|
58
|
+
from floe.read.orc import read_orc
|
|
59
|
+
from floe.read.parquet import read_parquet
|
|
60
|
+
from floe.read.xlsx import read_xlsx
|
|
61
|
+
from floe.read.xml import read_xml, xml_columns
|
|
62
|
+
from floe.report.entity import build_report, file_report
|
|
63
|
+
from floe.report.summary import run_outcome
|
|
64
|
+
from floe.run.events import NO_EVENTS, Emitter
|
|
65
|
+
from floe.state import (
|
|
66
|
+
CLAIM_TTL_SECONDS,
|
|
67
|
+
Claim,
|
|
68
|
+
EntityState,
|
|
69
|
+
FileState,
|
|
70
|
+
Loaded,
|
|
71
|
+
claim_all,
|
|
72
|
+
claim_inputs,
|
|
73
|
+
promote,
|
|
74
|
+
read_state,
|
|
75
|
+
release,
|
|
76
|
+
renew,
|
|
77
|
+
)
|
|
78
|
+
from floe.storage.archive import archive_input_file
|
|
79
|
+
from floe.storage.inputs import (
|
|
80
|
+
InputFile,
|
|
81
|
+
NoInputsError,
|
|
82
|
+
ResolvedInputs,
|
|
83
|
+
localize_input,
|
|
84
|
+
resolve_inputs,
|
|
85
|
+
suffixes_for_format,
|
|
86
|
+
)
|
|
87
|
+
from floe.storage.local import LocalClient
|
|
88
|
+
from floe.storage.partitioning import (
|
|
89
|
+
advance,
|
|
90
|
+
in_load_order,
|
|
91
|
+
listing_start,
|
|
92
|
+
partition_of,
|
|
93
|
+
source_root,
|
|
94
|
+
window,
|
|
95
|
+
)
|
|
96
|
+
from floe.storage.paths import normalize_local_path
|
|
97
|
+
from floe.storage.resolve import resolve_path, storage_client
|
|
98
|
+
from floe.storage.s3 import S3Client
|
|
99
|
+
from floe.storage.types import StorageClient
|
|
100
|
+
from floe.write.outputs import (
|
|
101
|
+
AcceptedWrite,
|
|
102
|
+
part_uris,
|
|
103
|
+
write_accepted,
|
|
104
|
+
write_rejected,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
Chunks = Iterator[tuple[int, pl.DataFrame, pl.Series | None]] # fmt: skip # offset, frame, ragged rows
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class EntityRun:
|
|
112
|
+
report: dict[str, Any]
|
|
113
|
+
aborted: bool
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _chunks(entity: EntityConfig, path: Path, names: list[str]) -> Chunks:
|
|
117
|
+
src = entity.source
|
|
118
|
+
match src.format:
|
|
119
|
+
case "csv" | "tsv":
|
|
120
|
+
for c in read_csv(path, src.options, names):
|
|
121
|
+
yield c.offset, c.df, c.ragged
|
|
122
|
+
case "parquet":
|
|
123
|
+
for p in read_parquet(path):
|
|
124
|
+
yield p.offset, p.df, None
|
|
125
|
+
case "avro":
|
|
126
|
+
for a in read_avro(path):
|
|
127
|
+
yield a.offset, a.df, None
|
|
128
|
+
case "orc":
|
|
129
|
+
for o in read_orc(path):
|
|
130
|
+
yield o.offset, o.df, None
|
|
131
|
+
case "xlsx":
|
|
132
|
+
yield 0, read_xlsx(path, src.options), None
|
|
133
|
+
case "xml":
|
|
134
|
+
for x in read_xml(path, src.options, entity.schema_.columns[: len(names)]):
|
|
135
|
+
yield x.offset, x.df, None
|
|
136
|
+
case "json": # #998
|
|
137
|
+
for j in read_json(path, src.options, entity.schema_.columns[: len(names)], src.cast_mode): # fmt: skip
|
|
138
|
+
yield j.offset, j.df, None
|
|
139
|
+
case other:
|
|
140
|
+
raise FloeRunError(
|
|
141
|
+
f"entity.name={entity.name} unsupported source format: {other}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _read_rule(fmt: str, message: str) -> str:
|
|
146
|
+
"""Rust's `FileReadError.rule`; the readers carry only the message (the XLSX ones prefix the rule)."""
|
|
147
|
+
head, _, _ = message.partition(": ")
|
|
148
|
+
if fmt == "xlsx" and head.startswith("xlsx_"):
|
|
149
|
+
return head
|
|
150
|
+
if fmt == "xml" and message.startswith(
|
|
151
|
+
("invalid selector", "duplicate xml selector")
|
|
152
|
+
):
|
|
153
|
+
return "xml_selector_invalid"
|
|
154
|
+
if fmt == "json": # Rust `JsonReadError.rule`
|
|
155
|
+
if message.startswith(("invalid selector", "duplicate json selector")):
|
|
156
|
+
return "json_selector_invalid"
|
|
157
|
+
if message.startswith("non-scalar value for selector"):
|
|
158
|
+
return "json_selector_non_scalar"
|
|
159
|
+
return "json_parse_error"
|
|
160
|
+
return "xml_parse_error" if fmt == "xml" else f"{fmt}_read_error"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _read_message(fmt: str, message: str) -> str:
|
|
164
|
+
head, _, rest = message.partition(": ")
|
|
165
|
+
return rest if fmt == "xlsx" and head.startswith("xlsx_") else message
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _failed_file(
|
|
169
|
+
entity: EntityConfig,
|
|
170
|
+
uri: str,
|
|
171
|
+
declared: int,
|
|
172
|
+
plan: MismatchOutcome | None,
|
|
173
|
+
error: FloeIoError,
|
|
174
|
+
rejected_path: str | None,
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""A file that could not be read: `rejected` (`aborted` under abort), no rows."""
|
|
177
|
+
fmt = entity.source.format
|
|
178
|
+
action = "aborted" if entity.policy.severity == "abort" else "rejected_file"
|
|
179
|
+
base = plan.report if plan else {}
|
|
180
|
+
report: dict[str, Any] = {
|
|
181
|
+
"declared_columns_count": declared,
|
|
182
|
+
"input_columns_count": base.get("input_columns_count", 0),
|
|
183
|
+
"missing_columns": base.get("missing_columns", []),
|
|
184
|
+
"extra_columns": base.get("extra_columns", []),
|
|
185
|
+
"mismatch_action": action,
|
|
186
|
+
"error": {
|
|
187
|
+
"rule": _read_rule(fmt, str(error)),
|
|
188
|
+
"message": f"entity.name={entity.name} {_read_message(fmt, str(error))}",
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
if "warning" in base:
|
|
192
|
+
report["warning"] = base["warning"]
|
|
193
|
+
outcome = MismatchOutcome(
|
|
194
|
+
report, False, False, plan.warnings if plan else 0, 1 + (plan.errors if plan else 0),
|
|
195
|
+
[], [], False, False,
|
|
196
|
+
) # fmt: skip
|
|
197
|
+
out = file_report(entity, uri, 0, 0, [], outcome, rejected_path)
|
|
198
|
+
out["status"] = "aborted" if action == "aborted" else "rejected"
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _rejected(plan: "_Plan", severity: str) -> tuple[str, StorageClient]:
|
|
203
|
+
"""The rejected sink's URI and client."""
|
|
204
|
+
if plan.rejected is None:
|
|
205
|
+
raise FloeConfigError(f"sink.rejected is required for {severity} severity")
|
|
206
|
+
return plan.rejected
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _put(plan: "_Plan", severity: str, name: str, body: bytes) -> str:
|
|
210
|
+
"""Write `<rejected>/<name>`; the report shows a local target as a path, as Rust."""
|
|
211
|
+
base, client = _rejected(plan, severity)
|
|
212
|
+
uri = f"{base.rstrip('/')}/{name}"
|
|
213
|
+
client.write(uri, body)
|
|
214
|
+
return uri.removeprefix("local://")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _copy_raw(plan: "_Plan", source: Path, severity: str, name: str) -> str:
|
|
218
|
+
# ponytail: the raw file is read into memory for the put; stream it (upload_file) if rejected files get large
|
|
219
|
+
return _put(plan, severity, name, source.read_bytes())
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _error_report(plan: "_Plan", stem: str, rows: list[tuple[int, str]]) -> str:
|
|
223
|
+
"""`<stem>_reject_errors.json`, Rust's `write_error_report`."""
|
|
224
|
+
items = ",".join(f'{{"row_index":{i},"errors":{e}}}' for i, e in rows)
|
|
225
|
+
return _put(plan, plan.entity.policy.severity, f"{stem}_reject_errors.json", f"[{items}]".encode()) # fmt: skip
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class _Plan:
|
|
230
|
+
"""Per-entity values every file needs."""
|
|
231
|
+
|
|
232
|
+
entity: EntityConfig
|
|
233
|
+
config_dir: Path
|
|
234
|
+
formatter: Formatter
|
|
235
|
+
strategy: str | None
|
|
236
|
+
names: list[str]
|
|
237
|
+
out_map: dict[str, str]
|
|
238
|
+
sources: dict[str, str]
|
|
239
|
+
required: list[str]
|
|
240
|
+
tracker: UniqueTracker
|
|
241
|
+
pii_map: dict[str, str]
|
|
242
|
+
rejected_writes: int = 0
|
|
243
|
+
prechecked: set[str] = field(
|
|
244
|
+
default_factory=set[str]
|
|
245
|
+
) # files the precheck settles (unreadable header, rejected or aborted mismatch); Rust settles them before it reads any file
|
|
246
|
+
archive: tuple[str, StorageClient] | None = None # Rust `archive_enabled()`
|
|
247
|
+
to_archive: list[InputFile] = field(default_factory=list[InputFile]) # fmt: skip # moved after the sink commit (#914)
|
|
248
|
+
run_id: str = ""
|
|
249
|
+
input_hash: str = (
|
|
250
|
+
"" # of the claimed files; names the rejected parts so a retry overwrites them
|
|
251
|
+
)
|
|
252
|
+
rejected: tuple[str, StorageClient] | None = None # `sink.rejected` URI and client
|
|
253
|
+
table: Table | None = (
|
|
254
|
+
None # the Iceberg target whose keys seed the tracker (append)
|
|
255
|
+
)
|
|
256
|
+
merge_key: str | None = (
|
|
257
|
+
None # the merge key's unique check; its duplicates are rejected even under warn (Rust `enforce_reject`)
|
|
258
|
+
)
|
|
259
|
+
derived: dict[str, list[pl.Expr]] = field(default_factory=dict[str, list[pl.Expr]]) # D28: by input URI # fmt: skip
|
|
260
|
+
root: str | None = None # D29: the source root, when `source.partitioning` is set
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _refuse_overlap(who: str, sink: str, inputs: list[InputFile]) -> None:
|
|
264
|
+
"""D34: an input under the accepted Parquet sink would be deleted or re-read as one of its parts."""
|
|
265
|
+
|
|
266
|
+
def real(
|
|
267
|
+
uri: str,
|
|
268
|
+
) -> str: # a local path through a symlink is judged by where it lands
|
|
269
|
+
return Path(uri.removeprefix("local://")).resolve().as_posix() if uri.startswith("local://") else uri # fmt: skip
|
|
270
|
+
|
|
271
|
+
root = real(sink).rstrip("/") + "/"
|
|
272
|
+
for f in inputs:
|
|
273
|
+
if real(f.source_uri).startswith(root):
|
|
274
|
+
raise FloeConfigError(f"{who} source file {f.source_uri} lies under sink.accepted.path {sink}; the sink would overwrite or re-read it") # fmt: skip
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _archive(plan: _Plan, file: InputFile) -> str | None:
|
|
278
|
+
"""Move the source file to `<archive>/<entity>/<stem>__run-…__src-….<ext>` (Rust `archive_input`).
|
|
279
|
+
|
|
280
|
+
D29: with `source.partitioning`, its partition sub-path is kept: `<archive>/<entity>/yyyy/MM/dd/<batch>/<stem>__run-…`.
|
|
281
|
+
"""
|
|
282
|
+
if plan.archive is None:
|
|
283
|
+
return None
|
|
284
|
+
base, client = plan.archive
|
|
285
|
+
if file.source_uri.startswith(f"{base}/"):
|
|
286
|
+
return None # D24: an archived input stays put
|
|
287
|
+
# Rust: copy, then delete the source; the report shows a local target as a path
|
|
288
|
+
sub = file.source_uri.removeprefix(f"{plan.root}/").rpartition("/")[0] if plan.root else "" # fmt: skip
|
|
289
|
+
uri = archive_input_file(client, plan.run_id, plan.entity.name, lambda rel: f"{base}/{rel}", file, sub) # fmt: skip
|
|
290
|
+
return uri.removeprefix("local://")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _with_archived(e: EntityConfig, archive: tuple[str, StorageClient], source: Callable[[], ResolvedInputs]) -> ResolvedInputs: # fmt: skip
|
|
294
|
+
"""D24: a full refresh reads `<archive>/<entity>/` first (by name; D29: in load order), then the source inputs; either may be empty, not both.
|
|
295
|
+
|
|
296
|
+
Only "no inputs matched" counts as empty: any other listing error fails the refresh rather than rebuild from part of the inputs.
|
|
297
|
+
"""
|
|
298
|
+
base, client = archive
|
|
299
|
+
p = e.source.partitioning
|
|
300
|
+
path = e.name if base.startswith("local://") else f"{base}/{e.name}/"
|
|
301
|
+
try:
|
|
302
|
+
old = resolve_inputs(Path(base.removeprefix("local://")), path, e.source.format, e.name, e.source.storage or "local", client, recursive=p is not None) # fmt: skip
|
|
303
|
+
except NoInputsError:
|
|
304
|
+
return (
|
|
305
|
+
source()
|
|
306
|
+
) # nothing archived: the source's "no input files matched" stands
|
|
307
|
+
if p is not None: # D29: the archive keeps the partition sub-paths
|
|
308
|
+
old = in_load_order(p, e.name, f"{base}/{e.name}", old, archived=True)
|
|
309
|
+
try:
|
|
310
|
+
new = source()
|
|
311
|
+
except NoInputsError: # no new source file: rebuild from the archive alone
|
|
312
|
+
return old
|
|
313
|
+
seen = set(old.listed) # an S3 source prefix also lists an archive nested under it
|
|
314
|
+
files = [f for f in new.files if f.source_uri not in seen]
|
|
315
|
+
return ResolvedInputs(old.files + files, old.listed + [u for u in new.listed if u not in seen], new.mode) # fmt: skip
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _output(plan: _Plan, df: pl.DataFrame) -> pl.DataFrame:
|
|
319
|
+
"""Output column names, then PII masking."""
|
|
320
|
+
df = df.rename(plan.out_map)
|
|
321
|
+
pii = plan.entity.pii
|
|
322
|
+
return apply_pii_masking(df, pii.columns, plan.pii_map) if pii else df
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _typed(
|
|
326
|
+
plan: _Plan, raw: pl.DataFrame, mismatch: MismatchOutcome, derived: list[pl.Expr]
|
|
327
|
+
) -> tuple[pl.DataFrame, list[RowCheck]]:
|
|
328
|
+
entity, src = plan.entity, plan.entity.source
|
|
329
|
+
# D28 (#913): the derived columns join after the file's own columns are normalised and matched
|
|
330
|
+
df = apply_mismatch(normalize_columns(raw, plan.strategy), mismatch).with_columns(
|
|
331
|
+
derived
|
|
332
|
+
)
|
|
333
|
+
df = df.select(
|
|
334
|
+
pl.col(n) if n in df.columns else pl.lit(None, pl.String).alias(n)
|
|
335
|
+
for n in plan.names
|
|
336
|
+
)
|
|
337
|
+
columns = entity.schema_.columns
|
|
338
|
+
try:
|
|
339
|
+
casts = [
|
|
340
|
+
cast_column(n, c.column_type, src.cast_mode, df.schema[n])
|
|
341
|
+
for n, c in zip(plan.names, columns, strict=True)
|
|
342
|
+
]
|
|
343
|
+
typed = df.with_columns(t for t, _ in casts)
|
|
344
|
+
masks = df.with_columns(
|
|
345
|
+
m for _, m in casts
|
|
346
|
+
) # unlike select, broadcasts constant masks
|
|
347
|
+
except pl.exceptions.InvalidOperationError as err:
|
|
348
|
+
raise FloeRunError(f"entity.name={entity.name} cast failed: {err}") from err
|
|
349
|
+
cast_checks = [
|
|
350
|
+
RowCheck("cast_error", n, CAST_ERROR_MESSAGE, masks[f"_e_cast_{n}"])
|
|
351
|
+
for n, c in zip(plan.names, columns, strict=True)
|
|
352
|
+
if c.column_type != "string"
|
|
353
|
+
] # fmt: skip
|
|
354
|
+
if entity.policy.severity == "abort" and any(c.mask.any() for c in cast_checks):
|
|
355
|
+
return typed, cast_checks # fmt: skip # Rust: abort-mode cast errors skip not_null and unique
|
|
356
|
+
required = [not_null_check(typed, n) for n in plan.required]
|
|
357
|
+
if plan.table is not None:
|
|
358
|
+
# ponytail: one key scan of the table per chunk; a DuckDB anti-join (ADR) if large histories make it slow
|
|
359
|
+
seed_from_table(plan.tracker, plan.table, typed, plan.out_map)
|
|
360
|
+
return typed, [*required, *cast_checks, *plan.tracker.check(typed)]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _run_file(plan: _Plan, file: InputFile, path: Path) -> tuple[dict[str, Any], list[pl.DataFrame]]: # fmt: skip
|
|
364
|
+
"""The file's report and its accepted frames (none when it aborted the run); `path` is the local copy."""
|
|
365
|
+
entity, uri = plan.entity, file.source_uri
|
|
366
|
+
severity = entity.policy.severity
|
|
367
|
+
derived = plan.derived.get(uri, [])
|
|
368
|
+
# D28: the derived columns come last in the schema and are not in the file
|
|
369
|
+
file_names = plan.names[: len(plan.names) - len(derived)]
|
|
370
|
+
derived = [e.alias(n) for e, n in zip(derived, plan.names[len(file_names) :], strict=True)] # fmt: skip
|
|
371
|
+
declared = [n for n in declared_names(entity) if n not in plan.names[len(file_names) :]] # fmt: skip
|
|
372
|
+
chunks = _chunks(entity, path, file_names)
|
|
373
|
+
keys = plan.tracker.snapshot()
|
|
374
|
+
|
|
375
|
+
def failed(err: FloeIoError, mismatch: MismatchOutcome | None) -> tuple[dict[str, Any], list[pl.DataFrame]]: # fmt: skip
|
|
376
|
+
# Rust skips the raw copy under PII: the file cannot be masked
|
|
377
|
+
plan.tracker.restore(keys) # the unreadable file leaves no keys behind
|
|
378
|
+
keep = entity.pii is None and entity.sink.rejected is not None
|
|
379
|
+
raw = _copy_raw(plan, path, severity, file.source_name) if keep else None
|
|
380
|
+
return _failed_file(entity, uri, len(declared) + len(derived), mismatch, err, raw), [] # fmt: skip
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
if entity.source.format == "xml":
|
|
384
|
+
first, probe = [], xml_columns(path, entity.source.options)
|
|
385
|
+
elif entity.source.format == "json":
|
|
386
|
+
# D40: every record's top-level keys; D41: only a file without records has nothing to compare
|
|
387
|
+
json_keys = json_input_columns(path, entity.source.options.json_mode or "array") # fmt: skip
|
|
388
|
+
first, probe = [], declared if json_keys is None else json_keys
|
|
389
|
+
else: # the first chunk's columns are the input columns the plan compares
|
|
390
|
+
first = [next(chunks)]
|
|
391
|
+
probe = list(first[0][1].columns)
|
|
392
|
+
except FloeIoError as err:
|
|
393
|
+
plan.prechecked.add(uri) # Rust `run_precheck` settles an unreadable header too
|
|
394
|
+
return failed(err, None)
|
|
395
|
+
mismatch = plan_mismatch(entity, declared, probe)
|
|
396
|
+
# D28: the derived columns are declared (through partitioning), not input columns
|
|
397
|
+
mismatch.report["declared_columns_count"] += len(derived)
|
|
398
|
+
if mismatch.rejected or mismatch.aborted:
|
|
399
|
+
plan.prechecked.add(uri)
|
|
400
|
+
raw = _copy_raw(plan, path, "abort" if mismatch.aborted else "reject", file.source_name) # fmt: skip
|
|
401
|
+
plan.to_archive.append(file)
|
|
402
|
+
return file_report(entity, uri, 0, 0, [], mismatch, raw), []
|
|
403
|
+
|
|
404
|
+
rows, bad_total, forced_total = 0, 0, 0
|
|
405
|
+
masks: dict[tuple[str, str, str], list[pl.Series]] = {}
|
|
406
|
+
accepted: list[pl.DataFrame] = []
|
|
407
|
+
rejected: list[pl.DataFrame] = []
|
|
408
|
+
errors: list[tuple[int, str]] = []
|
|
409
|
+
try:
|
|
410
|
+
for offset, raw_df, ragged in chain(first, chunks):
|
|
411
|
+
typed, checks = _typed(plan, raw_df, mismatch, derived)
|
|
412
|
+
if ragged is not None and ragged.any():
|
|
413
|
+
checks.insert(0, RowCheck("csv_read_error", "", RAGGED_MESSAGE, ragged))
|
|
414
|
+
good, bad = split_rows(typed, checks, plan.formatter, plan.sources)
|
|
415
|
+
bad = bad.with_columns(pl.col(ROW_INDEX) + offset)
|
|
416
|
+
rows += typed.height
|
|
417
|
+
bad_total += bad.height
|
|
418
|
+
for c in checks:
|
|
419
|
+
masks.setdefault((c.rule, c.column, c.message), []).append(c.mask)
|
|
420
|
+
errors += zip(bad[ROW_INDEX].to_list(), bad[ERRORS].to_list(), strict=True)
|
|
421
|
+
forced = next((c.mask for c in checks if c.rule == "unique" and c.column == plan.merge_key), None) # fmt: skip
|
|
422
|
+
if severity == "warn" and forced is not None:
|
|
423
|
+
forced = forced.fill_null(False)
|
|
424
|
+
forced_total += int(forced.sum())
|
|
425
|
+
typed = typed.filter(~forced)
|
|
426
|
+
accepted.append(good if severity == "reject" else typed)
|
|
427
|
+
if severity == "reject" and bad.height:
|
|
428
|
+
rejected.append(bad)
|
|
429
|
+
except FloeIoError as err:
|
|
430
|
+
return failed(err, mismatch)
|
|
431
|
+
|
|
432
|
+
merged = [RowCheck(r, c, m, pl.concat(s)) for (r, c, m), s in masks.items()]
|
|
433
|
+
rejected_path = errors_path = None
|
|
434
|
+
if bad_total and severity == "reject":
|
|
435
|
+
mode = entity.sink.write_mode
|
|
436
|
+
if mode == "overwrite" and plan.rejected_writes:
|
|
437
|
+
mode = "append"
|
|
438
|
+
plan.rejected_writes += 1
|
|
439
|
+
target, client = _rejected(plan, "reject")
|
|
440
|
+
written = write_rejected(_output(plan, pl.concat(rejected)), client, target, mode, f"{plan.input_hash}:{file.source_uri}") # fmt: skip
|
|
441
|
+
rejected_path = written.removeprefix("local://")
|
|
442
|
+
elif bad_total and severity == "abort":
|
|
443
|
+
rejected_path = _copy_raw(plan, path, "abort", file.source_name)
|
|
444
|
+
if bad_total and entity.sink.rejected and severity != "reject":
|
|
445
|
+
errors_path = _error_report(plan, file.source_stem, errors)
|
|
446
|
+
plan.to_archive.append(file)
|
|
447
|
+
out = file_report(entity, uri, rows, bad_total, merged, mismatch, rejected_path, None, errors_path, forced_total) # fmt: skip
|
|
448
|
+
return out, [] if out["status"] == "aborted" else [_output(plan, df) for df in accepted] # fmt: skip
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _seed_unique(plan: _Plan, client: StorageClient, base: str, columns: list[str]) -> None: # fmt: skip
|
|
452
|
+
"""Append mode: the keys already in the sink's parts count as seen (Rust `seed_unique_tracker_for_append`)."""
|
|
453
|
+
if not columns:
|
|
454
|
+
return # no unique keys: never read the history
|
|
455
|
+
stored = {n: plan.out_map.get(n, n) for n in columns} # the sink holds output names
|
|
456
|
+
for uri in part_uris(client, base.rstrip("/"), "parquet"):
|
|
457
|
+
part = uri.removeprefix("local://")
|
|
458
|
+
try: # only the key columns are read
|
|
459
|
+
body = client.read(uri)
|
|
460
|
+
keys = pl.read_parquet(io.BytesIO(body.body if body else b""), columns=list(stored.values())) # fmt: skip
|
|
461
|
+
except pl.exceptions.PolarsError as err:
|
|
462
|
+
raise FloeRunError(f"entity.name={plan.entity.name} cannot seed unique keys from {part}: {err}") from err # fmt: skip
|
|
463
|
+
plan.tracker.seed(keys.rename({v: k for k, v in stored.items()}))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _report_names(
|
|
467
|
+
r: UniqueResult, key: list[str], runtime: dict[str, str]
|
|
468
|
+
) -> UniqueResult:
|
|
469
|
+
"""The report names the schema columns, in `columns` and in the sample values."""
|
|
470
|
+
back = {runtime.get(c, c): c for c in key}
|
|
471
|
+
samples = [replace(x, values={back.get(k, k): v for k, v in x.values.items()}) for x in r.samples] # fmt: skip
|
|
472
|
+
return replace(r, columns=key, samples=samples)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
RENEW_INTERVAL_S = float(min(max(CLAIM_TTL_SECONDS // 3, 1), 300))
|
|
476
|
+
_SEPARATORS = "/\\"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _parent(value: str) -> str | None:
|
|
480
|
+
i = max(value.rfind(s) for s in _SEPARATORS)
|
|
481
|
+
return None if i < 0 else value[: max(i, 1)]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _source_root(raw: str, fmt: str, resolved: Path | None) -> str:
|
|
485
|
+
"""Rust `derive_source_root`: the directory the default state lives under."""
|
|
486
|
+
trimmed = raw.rstrip(_SEPARATORS)
|
|
487
|
+
if not trimmed:
|
|
488
|
+
return ""
|
|
489
|
+
cut = next((i for i, c in enumerate(trimmed) if c in "*?["), -1)
|
|
490
|
+
if cut >= 0:
|
|
491
|
+
prefix = trimmed[:cut]
|
|
492
|
+
done = prefix if not prefix or prefix[-1] in _SEPARATORS else _parent(prefix) or prefix # fmt: skip
|
|
493
|
+
return done.rstrip(_SEPARATORS)
|
|
494
|
+
if resolved and resolved.is_dir(): # a remote source has no local path to probe
|
|
495
|
+
return trimmed
|
|
496
|
+
if (resolved and resolved.is_file()) or trimmed.lower().endswith(
|
|
497
|
+
tuple(suffixes_for_format(fmt))
|
|
498
|
+
):
|
|
499
|
+
# #813: a bare filename lives in the config dir (Rust 0.7 used the file itself)
|
|
500
|
+
return (_parent(trimmed) or ".").rstrip(_SEPARATORS)
|
|
501
|
+
return trimmed
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def state_uri(entity: EntityConfig, config_dir: Path, storages: StoragesConfig | None = None) -> tuple[str, StorageClient]: # fmt: skip
|
|
505
|
+
"""`state.path` if set (a URI on the source storage, else local), else `<source root>/.floe/state/<domain>/<name>/state.json` on the source storage (Rust `resolve_entity_state_path`)."""
|
|
506
|
+
src, field = entity.source, f"entity.name={entity.name} entity.state.path"
|
|
507
|
+
custom = entity.state and entity.state.path
|
|
508
|
+
if custom and "://" not in custom:
|
|
509
|
+
return f"local://{normalize_local_path(config_dir / custom)}", LocalClient()
|
|
510
|
+
if not custom:
|
|
511
|
+
source_uri, _ = resolve_path(storages, config_dir, src.storage, src.path, field)
|
|
512
|
+
local = source_uri.startswith("local://")
|
|
513
|
+
root = _source_root(src.path, src.format, Path(source_uri.removeprefix("local://")) if local else None) # fmt: skip
|
|
514
|
+
ident = f"{entity.domain}/{entity.name}" if entity.domain and entity.id != entity.name else entity.name # fmt: skip
|
|
515
|
+
tail = f".floe/state/{ident}/state.json"
|
|
516
|
+
custom = f"{root}/{tail}" if root and root != "." else tail
|
|
517
|
+
uri, definition = resolve_path(storages, config_dir, src.storage, custom, field)
|
|
518
|
+
return uri, storage_client(definition)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def skipped_report(uri: str, reason: str, warning: str | None = None) -> dict[str, Any]:
|
|
522
|
+
mismatch: dict[str, Any] = {
|
|
523
|
+
"declared_columns_count": 0,
|
|
524
|
+
"input_columns_count": 0,
|
|
525
|
+
"missing_columns": [],
|
|
526
|
+
"extra_columns": [],
|
|
527
|
+
"mismatch_action": "none",
|
|
528
|
+
}
|
|
529
|
+
if warning:
|
|
530
|
+
mismatch["warning"] = warning
|
|
531
|
+
return {
|
|
532
|
+
"input_file": uri,
|
|
533
|
+
"status": "skipped",
|
|
534
|
+
"row_count": 0,
|
|
535
|
+
"accepted_count": 0,
|
|
536
|
+
"rejected_count": 0,
|
|
537
|
+
"skip_reason": reason,
|
|
538
|
+
"mismatch": mismatch,
|
|
539
|
+
"output": {
|
|
540
|
+
"accepted_path": None,
|
|
541
|
+
"rejected_path": None,
|
|
542
|
+
"errors_path": None,
|
|
543
|
+
"archived_path": None,
|
|
544
|
+
},
|
|
545
|
+
"validation": {"errors": 0, "warnings": int(bool(warning)), "rules": []},
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def skipped_reports(
|
|
550
|
+
entity: EntityConfig,
|
|
551
|
+
already: list[tuple[InputFile, FileState]],
|
|
552
|
+
active: list[str],
|
|
553
|
+
) -> list[dict[str, Any]]:
|
|
554
|
+
out: list[dict[str, Any]] = []
|
|
555
|
+
for f, rec in already:
|
|
556
|
+
if (rec.size, rec.mtime) == (f.source_size, f.source_mtime):
|
|
557
|
+
out.append(skipped_report(f.source_uri, "already_ingested"))
|
|
558
|
+
continue
|
|
559
|
+
msg = (
|
|
560
|
+
f"entity.name={entity.name} incremental_mode=file skipping previously ingested file with changed metadata: {f.source_uri} "
|
|
561
|
+
f"(recorded size={rec.size}, mtime={rec.mtime}; current size={f.source_size}, mtime={f.source_mtime})"
|
|
562
|
+
) # fmt: skip
|
|
563
|
+
out.append(skipped_report(f.source_uri, "already_ingested_changed", msg))
|
|
564
|
+
out += [
|
|
565
|
+
skipped_report(
|
|
566
|
+
u, "active_claim",
|
|
567
|
+
f"entity.name={entity.name} incremental_mode=file skipping file claimed by another active run: {u}",
|
|
568
|
+
)
|
|
569
|
+
for u in active
|
|
570
|
+
] # fmt: skip
|
|
571
|
+
return out
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _emit_skipped(emit: Emitter, entity: str, reports: list[dict[str, Any]]) -> None:
|
|
575
|
+
for r in reports:
|
|
576
|
+
emit("file_finished", entity=entity, input=r["input_file"], status="skipped",
|
|
577
|
+
skip_reason=r["skip_reason"], rows=0, accepted=0, rejected=0, elapsed_ms=0) # fmt: skip
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class Incremental:
|
|
581
|
+
"""Claims the entity's inputs, renews the claims while it runs, then promotes or releases them."""
|
|
582
|
+
|
|
583
|
+
def __init__(self, entity: EntityConfig, config_dir: Path, run_id: str, full_refresh: bool = False, storages: StoragesConfig | None = None) -> None: # fmt: skip
|
|
584
|
+
self.entity, self.config_dir, self.run_id = entity, config_dir, run_id
|
|
585
|
+
self.storages = storages
|
|
586
|
+
self.full_refresh = full_refresh
|
|
587
|
+
self.client: StorageClient = LocalClient()
|
|
588
|
+
self.uri = ""
|
|
589
|
+
self.claimed: Loaded | None = None
|
|
590
|
+
self.expired: dict[str, Claim] = {} # the dead runs' claims this run took over
|
|
591
|
+
# the file reports, set once the sink step is done (#915)
|
|
592
|
+
self.committed: list[dict[str, Any]] | None = None
|
|
593
|
+
self.advance: Callable[[EntityState, list[str]], None] | None = None # D30
|
|
594
|
+
self._stop = threading.Event()
|
|
595
|
+
self._thread: threading.Thread | None = None
|
|
596
|
+
|
|
597
|
+
def claim(self, inputs: list[InputFile], emit: Emitter) -> tuple[list[InputFile], list[dict[str, Any]]]: # fmt: skip
|
|
598
|
+
"""The inputs left to process and the reports of the skipped ones; `row` mode and a full refresh never skip (Rust 0.7)."""
|
|
599
|
+
e = self.entity
|
|
600
|
+
if e.incremental_mode != "file":
|
|
601
|
+
return inputs, []
|
|
602
|
+
self.uri, self.client = state_uri(e, self.config_dir, self.storages)
|
|
603
|
+
if self.full_refresh:
|
|
604
|
+
self._hold(claim_all(self.client, self.uri, e.name, self.run_id, inputs))
|
|
605
|
+
return inputs, []
|
|
606
|
+
got = claim_inputs(self.client, self.uri, e.name, self.run_id, inputs, e.sink.write_mode.startswith("merge")) # fmt: skip
|
|
607
|
+
reports = skipped_reports(e, got.already_processed, got.active_claims)
|
|
608
|
+
_emit_skipped(emit, e.name, reports)
|
|
609
|
+
self.expired = got.expired
|
|
610
|
+
if got.claimed:
|
|
611
|
+
self._hold(got.claimed)
|
|
612
|
+
return got.pending, reports
|
|
613
|
+
|
|
614
|
+
def skip_committed(self, table: Table | None, pending: list[InputFile], emit: Emitter) -> list[dict[str, Any]]: # fmt: skip
|
|
615
|
+
"""#545 retry guard (append): the reports of the pending inputs a dead run already committed, before any is read.
|
|
616
|
+
|
|
617
|
+
`file` mode re-derives each dead run's batch from its expired claims (by `run_id`, with their size and mtime),
|
|
618
|
+
so a retry that also holds new inputs still matches; it looks back to that run's claim. Other modes match the
|
|
619
|
+
whole set.
|
|
620
|
+
"""
|
|
621
|
+
e = self.entity
|
|
622
|
+
if table is None or not pending or self.full_refresh:
|
|
623
|
+
return []
|
|
624
|
+
if e.incremental_mode != "file":
|
|
625
|
+
done = pending if find_committed(table, input_hash(e, pending)) else []
|
|
626
|
+
return self._skipped(done, emit)
|
|
627
|
+
ours = {f.source_uri for f in pending}
|
|
628
|
+
runs: dict[str, dict[str, Claim]] = {}
|
|
629
|
+
for u, c in self.expired.items():
|
|
630
|
+
runs.setdefault(c.run_id, {})[u] = c
|
|
631
|
+
committed: set[str] = set()
|
|
632
|
+
for claims in runs.values():
|
|
633
|
+
if ours.isdisjoint(claims):
|
|
634
|
+
continue # new inputs, or a reload after `floe state reset`
|
|
635
|
+
since = min(datetime.fromisoformat(c.acquired_at) for c in claims.values())
|
|
636
|
+
batch = [InputFile(u, "", "", c.size, c.mtime) for u, c in claims.items()]
|
|
637
|
+
found = find_committed(table, input_hash(e, batch), since)
|
|
638
|
+
# fail closed; `run_entity` releases, which puts the dead runs' claims back so the next run fails too
|
|
639
|
+
if found is None:
|
|
640
|
+
raise FloeRunError(
|
|
641
|
+
f"entity.name={e.name} cannot tell whether a run that died after claiming its inputs at {since} "
|
|
642
|
+
"committed them: the table's snapshots since then were expired (retention floor: 7 days and at least "
|
|
643
|
+
"10 snapshots); run `floe state reset` for the entity, then rerun"
|
|
644
|
+
) # fmt: skip
|
|
645
|
+
if found:
|
|
646
|
+
committed |= claims.keys() & ours
|
|
647
|
+
# promote now, so a later failure of the other inputs can't release them
|
|
648
|
+
if committed and self.claimed:
|
|
649
|
+
promote(self.client, self.uri, self.run_id, self.claimed, only=committed, advance=self.advance) # fmt: skip
|
|
650
|
+
return self._skipped([f for f in pending if f.source_uri in committed], emit)
|
|
651
|
+
|
|
652
|
+
def _skipped(self, files: list[InputFile], emit: Emitter) -> list[dict[str, Any]]:
|
|
653
|
+
reports = [skipped_report(f.source_uri, "already_committed") for f in files]
|
|
654
|
+
_emit_skipped(emit, self.entity.name, reports)
|
|
655
|
+
return reports
|
|
656
|
+
|
|
657
|
+
def _hold(self, claimed: Loaded) -> None:
|
|
658
|
+
self.claimed = claimed
|
|
659
|
+
self._thread = threading.Thread(target=self._beat, daemon=True)
|
|
660
|
+
self._thread.start()
|
|
661
|
+
|
|
662
|
+
def _beat(self) -> None:
|
|
663
|
+
while not self._stop.wait(RENEW_INTERVAL_S):
|
|
664
|
+
with suppress(Exception): # Rust ignores renewal errors
|
|
665
|
+
if self.claimed:
|
|
666
|
+
renew(self.client, self.uri, self.run_id, self.claimed)
|
|
667
|
+
|
|
668
|
+
def _end(self) -> Loaded | None:
|
|
669
|
+
self._stop.set()
|
|
670
|
+
if self._thread:
|
|
671
|
+
self._thread.join()
|
|
672
|
+
claimed, self.claimed = self.claimed, None
|
|
673
|
+
return claimed
|
|
674
|
+
|
|
675
|
+
def release(self) -> None:
|
|
676
|
+
"""The dead runs' claims this run took over go back: the next run checks them again (#545)."""
|
|
677
|
+
if claimed := self._end():
|
|
678
|
+
# D30: an older day's released file stays listed for a retry
|
|
679
|
+
release(self.client, self.uri, self.run_id, claimed, self.expired, self.advance is not None and not self.full_refresh) # fmt: skip
|
|
680
|
+
|
|
681
|
+
def finish(self, files: list[dict[str, Any]]) -> None:
|
|
682
|
+
"""Promote unless the entity failed (Rust commits on success, warnings and rejected).
|
|
683
|
+
|
|
684
|
+
#918: an abort still writes the files before it (Rust 0.7), so it promotes those and releases the rest;
|
|
685
|
+
under `overwrite` it releases them all, as the retry rebuilds the output from every file.
|
|
686
|
+
"""
|
|
687
|
+
status, _ = run_outcome([f["status"] for f in files])
|
|
688
|
+
if status == "failed" or (status == "aborted" and self.entity.sink.write_mode == "overwrite"): # fmt: skip
|
|
689
|
+
return self.release()
|
|
690
|
+
if claimed := self._end():
|
|
691
|
+
only = {f["input_file"] for f in files if f["status"] != "aborted"} if status == "aborted" else None # fmt: skip
|
|
692
|
+
promote(self.client, self.uri, self.run_id, claimed, self.full_refresh, only, self.advance) # fmt: skip
|
|
693
|
+
if (
|
|
694
|
+
only is not None
|
|
695
|
+
): # D30: the aborted day's released files stay listed for a retry
|
|
696
|
+
release(self.client, self.uri, self.run_id, claimed, self.expired, self.advance is not None and not self.full_refresh) # fmt: skip
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def resolve_source(entity: EntityConfig, config_dir: Path, storages: StoragesConfig | None) -> tuple[str, LocalClient | S3Client]: # fmt: skip
|
|
700
|
+
"""`source.path` as a URI, and the client of its storage."""
|
|
701
|
+
src = entity.source
|
|
702
|
+
uri, definition = resolve_path(storages, config_dir, src.storage, src.path, f"entity.name={entity.name} source.storage") # fmt: skip
|
|
703
|
+
return uri, storage_client(definition)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def archive_location(entity: EntityConfig, config_dir: Path, storages: StoragesConfig | None) -> tuple[str, StorageDefinition | None] | None: # fmt: skip
|
|
707
|
+
"""`sink.archive` as a URI (no trailing `/`) and its storage definition."""
|
|
708
|
+
archive, src = entity.sink.archive, entity.source
|
|
709
|
+
if archive is None:
|
|
710
|
+
return None
|
|
711
|
+
uri, definition = resolve_path(storages, config_dir, archive.storage or src.storage, archive.path, f"entity.name={entity.name} sink.archive.storage") # fmt: skip
|
|
712
|
+
return uri.rstrip("/"), definition
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _derived(entity: EntityConfig, source_uri: str, archive: str | None, files: list[InputFile]) -> dict[str, list[pl.Expr]]: # fmt: skip
|
|
716
|
+
"""D28: each input's date (and batch) column values, from its path."""
|
|
717
|
+
p = entity.source.partitioning
|
|
718
|
+
if p is None:
|
|
719
|
+
return {}
|
|
720
|
+
root, out = source_root(source_uri), dict[str, list[pl.Expr]]()
|
|
721
|
+
for f in files:
|
|
722
|
+
archived = archive and f.source_uri.startswith(f"{archive}/") # D29: a full refresh # fmt: skip
|
|
723
|
+
part = partition_of(p, entity.name, f"{archive}/{entity.name}" if archived else root, f.source_uri) # fmt: skip
|
|
724
|
+
out[f.source_uri] = [pl.lit(part.when).alias(p.date_column)]
|
|
725
|
+
if p.batch_column:
|
|
726
|
+
out[f.source_uri].append(
|
|
727
|
+
pl.lit(part.batch, pl.String).alias(p.batch_column)
|
|
728
|
+
)
|
|
729
|
+
return out
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def list_source(entity: EntityConfig, config_dir: Path, storages: StoragesConfig | None, uri: str, client: StorageClient, full_refresh: bool = False) -> ResolvedInputs: # fmt: skip
|
|
733
|
+
"""The source inputs; a local path keeps its globs relative to the config.
|
|
734
|
+
|
|
735
|
+
D31: the archive is never listed as source input; only a full refresh reads it, explicitly (D24).
|
|
736
|
+
D30: `file` mode with partitioning lists only from the state's window.
|
|
737
|
+
"""
|
|
738
|
+
src, archive = entity.source, archive_location(entity, config_dir, storages)
|
|
739
|
+
p, root, state = src.partitioning, source_root(uri), None
|
|
740
|
+
if p and entity.incremental_mode == "file" and not full_refresh:
|
|
741
|
+
at, state_client = state_uri(entity, config_dir, storages)
|
|
742
|
+
state = read_state(state_client, at, entity.name)
|
|
743
|
+
start = window(p, entity.name, root, state) if p else None
|
|
744
|
+
after = listing_start(p, start) if p and start else None
|
|
745
|
+
|
|
746
|
+
def source() -> ResolvedInputs:
|
|
747
|
+
inputs = resolve_inputs(
|
|
748
|
+
config_dir, src.path if uri.startswith("local://") else uri, src.format, entity.name,
|
|
749
|
+
src.storage or "local", client, glob_override=src.options.glob, exclude=archive and archive[0],
|
|
750
|
+
recursive=bool(src.options.recursive) or p is not None, # D28: partitioning lists recursively
|
|
751
|
+
after=f"{root}/{after}" if after else "",
|
|
752
|
+
) # fmt: skip
|
|
753
|
+
if not p:
|
|
754
|
+
return inputs
|
|
755
|
+
inputs = in_load_order(p, entity.name, root, inputs, start=start)
|
|
756
|
+
if start is None or state is None:
|
|
757
|
+
return inputs
|
|
758
|
+
late = f"entity.name={entity.name} incremental_mode=file lists source.partitioning dates from {start} (watermark {state.watermark}, lookback_days {p.lookback_days}); a late file in an older day is not ingested until --full-refresh" # fmt: skip
|
|
759
|
+
return replace(inputs, warning=late)
|
|
760
|
+
|
|
761
|
+
if full_refresh and archive and entity.incremental_mode in ("none", "archive"):
|
|
762
|
+
return _with_archived(entity, (archive[0], storage_client(archive[1])), source)
|
|
763
|
+
return source()
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def run_entity(
|
|
767
|
+
entity: EntityConfig,
|
|
768
|
+
config_dir: Path,
|
|
769
|
+
spec_version: str,
|
|
770
|
+
formatter: Formatter = "json",
|
|
771
|
+
storages: StoragesConfig | None = None,
|
|
772
|
+
emit: Emitter = NO_EVENTS,
|
|
773
|
+
full_refresh: bool = False,
|
|
774
|
+
catalogs: CatalogsConfig | None = None,
|
|
775
|
+
listed: ResolvedInputs | None = None,
|
|
776
|
+
) -> EntityRun:
|
|
777
|
+
"""Run one entity on local or S3 storage, with Parquet or Iceberg sinks; `config_dir` is absolute and normalised."""
|
|
778
|
+
inc = Incremental(entity, config_dir, emit.run_id, full_refresh, storages)
|
|
779
|
+
try:
|
|
780
|
+
run = _run_entity(
|
|
781
|
+
entity,
|
|
782
|
+
config_dir,
|
|
783
|
+
spec_version,
|
|
784
|
+
formatter,
|
|
785
|
+
storages,
|
|
786
|
+
emit,
|
|
787
|
+
inc,
|
|
788
|
+
catalogs,
|
|
789
|
+
listed,
|
|
790
|
+
)
|
|
791
|
+
except BaseException:
|
|
792
|
+
if inc.committed is None:
|
|
793
|
+
inc.release() # a failed entity leaves no claims behind
|
|
794
|
+
else: # #915: the sink holds the rows, so a release would let a rerun write them again
|
|
795
|
+
inc.finish(inc.committed)
|
|
796
|
+
raise
|
|
797
|
+
inc.finish(run.report["files"])
|
|
798
|
+
return run
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _run_entity(
|
|
802
|
+
entity: EntityConfig,
|
|
803
|
+
config_dir: Path,
|
|
804
|
+
spec_version: str,
|
|
805
|
+
formatter: Formatter,
|
|
806
|
+
storages: StoragesConfig | None,
|
|
807
|
+
emit: Emitter,
|
|
808
|
+
inc: Incremental,
|
|
809
|
+
catalogs: CatalogsConfig | None,
|
|
810
|
+
listed: ResolvedInputs | None,
|
|
811
|
+
) -> EntityRun:
|
|
812
|
+
entity = with_partition_columns(
|
|
813
|
+
entity
|
|
814
|
+
) # D28: the derived columns are schema columns
|
|
815
|
+
sink = entity.sink
|
|
816
|
+
if sink.accepted.format not in ("parquet", "iceberg"):
|
|
817
|
+
raise FloeRunError(f"entity.name={entity.name} unsupported sink format: {sink.accepted.format}") # fmt: skip
|
|
818
|
+
iceberg = sink.accepted.format == "iceberg"
|
|
819
|
+
who = f"entity.name={entity.name}"
|
|
820
|
+
source_uri, client = resolve_source(entity, config_dir, storages)
|
|
821
|
+
target = resolve_target(storages, catalogs, config_dir, entity) if iceberg else None
|
|
822
|
+
# PyIceberg writes an Iceberg table's files, not Floe's storage clients
|
|
823
|
+
base, accepted = resolve_path(storages, config_dir, sink.accepted.storage, sink.accepted.path, f"{who} sink.accepted.storage") # fmt: skip
|
|
824
|
+
rejected = sink.rejected and resolve_path(storages, config_dir, sink.rejected.storage, sink.rejected.path, f"{who} sink.rejected.storage") # fmt: skip
|
|
825
|
+
strategy = resolve_strategy(entity.schema_)
|
|
826
|
+
columns = entity.schema_.columns
|
|
827
|
+
names = source_names(columns, strategy)
|
|
828
|
+
runtime = {c.name: n for c, n in zip(columns, names, strict=True)}
|
|
829
|
+
keys = resolve_unique_keys(
|
|
830
|
+
entity.schema_.unique_keys,
|
|
831
|
+
[c.name for c in columns if c.unique],
|
|
832
|
+
entity.schema_.primary_key,
|
|
833
|
+
)
|
|
834
|
+
primary = {runtime.get(k.strip(), k.strip()) for k in entity.schema_.primary_key or []} # fmt: skip
|
|
835
|
+
plan = _Plan(
|
|
836
|
+
entity, config_dir, formatter, strategy, names,
|
|
837
|
+
output_column_mapping(columns, strategy),
|
|
838
|
+
{n: c.source for n, c in zip(names, columns, strict=True) if c.source},
|
|
839
|
+
[n for n, c in zip(names, columns, strict=True) if c.nullable is False or n in primary],
|
|
840
|
+
UniqueTracker([[runtime.get(k, k) for k in key] for key in keys]),
|
|
841
|
+
{
|
|
842
|
+
c.name: n
|
|
843
|
+
for c in columns
|
|
844
|
+
if (n := c.name if c.source else normalize_name(c.name, strategy)) != c.name
|
|
845
|
+
},
|
|
846
|
+
run_id=emit.run_id, rejected=rejected and (rejected[0], storage_client(rejected[1])),
|
|
847
|
+
) # fmt: skip
|
|
848
|
+
location = archive_location(entity, config_dir, storages)
|
|
849
|
+
if location and entity.incremental_mode in ("none", "archive"):
|
|
850
|
+
plan.archive = location[0], storage_client(location[1])
|
|
851
|
+
plan.root = source_root(source_uri) if entity.source.partitioning else None
|
|
852
|
+
if sink.write_mode in ("merge_scd1", "merge_scd2") and entity.schema_.primary_key:
|
|
853
|
+
pk = [runtime.get(k.strip(), k.strip()) for k in entity.schema_.primary_key]
|
|
854
|
+
plan.merge_key = (
|
|
855
|
+
pk[0] if len(pk) == 1 else f"[{','.join(pk)}]"
|
|
856
|
+
) # the tracker's check name
|
|
857
|
+
accepted_client = storage_client(accepted)
|
|
858
|
+
if sink.write_mode == "append" and target is None:
|
|
859
|
+
_seed_unique(
|
|
860
|
+
plan,
|
|
861
|
+
accepted_client,
|
|
862
|
+
base,
|
|
863
|
+
sorted({runtime.get(c, c) for k in keys for c in k}),
|
|
864
|
+
)
|
|
865
|
+
elif sink.write_mode == "append" and target is not None:
|
|
866
|
+
plan.table = existing_table(target) # fmt: skip # Rust `seed_unique_tracker`, and the retry guard
|
|
867
|
+
|
|
868
|
+
def source() -> ResolvedInputs:
|
|
869
|
+
# `listed`: the run's pre-check already listed the source (#864)
|
|
870
|
+
return (
|
|
871
|
+
listed
|
|
872
|
+
if listed is not None
|
|
873
|
+
else list_source(
|
|
874
|
+
entity, config_dir, storages, source_uri, client, inc.full_refresh
|
|
875
|
+
)
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
inputs = source()
|
|
879
|
+
if inputs.warning: # D30
|
|
880
|
+
emit("log", log_level="warn", code=None, message=inputs.warning, entity=entity.name, input=None) # fmt: skip
|
|
881
|
+
if entity.source.partitioning and entity.incremental_mode == "file":
|
|
882
|
+
dated = entity.source.partitioning, entity.name, source_root(source_uri)
|
|
883
|
+
inc.advance = partial(advance, *dated)
|
|
884
|
+
archive = plan.archive # a full refresh lists it too
|
|
885
|
+
plan.derived = _derived(entity, source_uri, archive and archive[0], inputs.files)
|
|
886
|
+
# D34 (#931): same store only (a bucket name repeats across endpoints); before the claim, so a refusal leaves nothing to release
|
|
887
|
+
if target is None and getattr(client, "endpoint", None) == getattr(accepted_client, "endpoint", None): # fmt: skip
|
|
888
|
+
_refuse_overlap(who, base, inputs.files)
|
|
889
|
+
pending, files = inc.claim(inputs.files, emit)
|
|
890
|
+
if done := inc.skip_committed(plan.table, pending, emit):
|
|
891
|
+
files += done
|
|
892
|
+
skip = {r["input_file"] for r in done}
|
|
893
|
+
# `archive` mode: the archive is the marker
|
|
894
|
+
plan.to_archive += [f for f in pending if f.source_uri in skip]
|
|
895
|
+
pending = [f for f in pending if f.source_uri not in skip]
|
|
896
|
+
plan.input_hash = input_hash(entity, pending)
|
|
897
|
+
frames: list[pl.DataFrame] = []
|
|
898
|
+
# #545: the snapshot's fingerprint names only the files it holds in full, never an aborted batch
|
|
899
|
+
complete: list[InputFile] = []
|
|
900
|
+
skipped = len(files)
|
|
901
|
+
downloader = None if isinstance(client, LocalClient) else client
|
|
902
|
+
# Rust `localize_input`: a remote input is read from a local copy
|
|
903
|
+
with tempfile.TemporaryDirectory(prefix="floe-") as temp_dir:
|
|
904
|
+
for f in pending:
|
|
905
|
+
emit("file_started", entity=entity.name, input=f.source_uri)
|
|
906
|
+
timer = time.monotonic()
|
|
907
|
+
local = localize_input(f, downloader, Path(temp_dir))
|
|
908
|
+
report, kept = _run_file(plan, f, local.local_path)
|
|
909
|
+
if local.is_ephemeral:
|
|
910
|
+
local.local_path.unlink()
|
|
911
|
+
emit("file_finished", entity=entity.name, input=f.source_uri, status=report["status"],
|
|
912
|
+
rows=report["row_count"], accepted=report["accepted_count"],
|
|
913
|
+
rejected=report["rejected_count"], elapsed_ms=int((time.monotonic() - timer) * 1000)) # fmt: skip
|
|
914
|
+
files.append(report)
|
|
915
|
+
frames += kept
|
|
916
|
+
if report["status"] == "aborted":
|
|
917
|
+
break
|
|
918
|
+
complete.append(f)
|
|
919
|
+
files[skipped:] = sorted(files[skipped:], key=lambda r: r["input_file"] not in plan.prechecked) # fmt: skip
|
|
920
|
+
|
|
921
|
+
table: dict[str, Any] | None = None
|
|
922
|
+
merge: dict[str, Any] | None = None
|
|
923
|
+
if pending and (
|
|
924
|
+
frames or sink.write_mode == "overwrite"
|
|
925
|
+
): # Rust writes nothing when every input was skipped
|
|
926
|
+
empty = pl.DataFrame(schema=dict.fromkeys(names, pl.String)).select(
|
|
927
|
+
cast_column(n, c.column_type)[0]
|
|
928
|
+
for n, c in zip(names, columns, strict=True)
|
|
929
|
+
)
|
|
930
|
+
frames = frames or [_output(plan, empty)]
|
|
931
|
+
if target is None:
|
|
932
|
+
write = write_accepted(pl.concat(frames), accepted_client, base, sink.write_mode, sink.accepted.options) # fmt: skip
|
|
933
|
+
else:
|
|
934
|
+
outputs = [plan.out_map.get(n, n) for n in names]
|
|
935
|
+
result, table, merge = write_iceberg(entity, target, frames, outputs, sink.write_mode, emit.run_id, input_hash(entity, complete)) # fmt: skip
|
|
936
|
+
write = AcceptedWrite(result.files_written, result.file_paths, result.total_bytes_written,
|
|
937
|
+
result.avg_file_size_mb, result.small_files_count) # fmt: skip
|
|
938
|
+
if result.already_committed: # #920: the commit conflicted and the guard found this batch in the table
|
|
939
|
+
done = {f.source_uri for f in complete}
|
|
940
|
+
files[:] = [skipped_report(r["input_file"], "already_committed") if r["input_file"] in done else r for r in files] # fmt: skip
|
|
941
|
+
else:
|
|
942
|
+
write = AcceptedWrite(0, [], 0, 0.0, 0)
|
|
943
|
+
inc.committed = files # #915: from here on a failure promotes the written files
|
|
944
|
+
# #914: archive only once the sink committed, so a failed commit leaves the inputs in the source (Rust archives before)
|
|
945
|
+
by_uri = {r["input_file"]: r for r in files}
|
|
946
|
+
for f in plan.to_archive:
|
|
947
|
+
by_uri[f.source_uri]["output"]["archived_path"] = _archive(plan, f)
|
|
948
|
+
unique = [_report_names(r, key, runtime) for r, key in zip(plan.tracker.results(), keys, strict=True)] # fmt: skip
|
|
949
|
+
report = build_report(
|
|
950
|
+
entity,
|
|
951
|
+
spec_version,
|
|
952
|
+
inputs,
|
|
953
|
+
files,
|
|
954
|
+
write,
|
|
955
|
+
source_uri=source_uri,
|
|
956
|
+
accepted_uri=target.accepted_uri if target else base,
|
|
957
|
+
rejected_uri=rejected[0] if rejected else None,
|
|
958
|
+
unique=unique,
|
|
959
|
+
table_root_uri=target.location if target and table else None,
|
|
960
|
+
table=table,
|
|
961
|
+
merge=merge,
|
|
962
|
+
)
|
|
963
|
+
return EntityRun(report, any(f["status"] == "aborted" for f in files))
|