floe-python 0.8.7__py3-none-any.whl → 0.8.9__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 +1 -2
- floe/_cli_output.py +24 -11
- floe/checks/mismatch.py +24 -9
- floe/checks/unique.py +4 -44
- floe/config/profile.py +10 -1
- floe/config/project.py +1 -0
- floe/iceberg/catalog.py +54 -42
- floe/iceberg/sink.py +11 -7
- floe/iceberg/write.py +46 -9
- floe/lineage/observer.py +13 -11
- floe/manifest_run.py +5 -0
- floe/read/json.py +9 -2
- floe/run/__init__.py +28 -12
- floe/run/entity.py +9 -6
- floe/run/parallel.py +71 -0
- {floe_python-0.8.7.dist-info → floe_python-0.8.9.dist-info}/METADATA +1 -1
- {floe_python-0.8.7.dist-info → floe_python-0.8.9.dist-info}/RECORD +20 -19
- {floe_python-0.8.7.dist-info → floe_python-0.8.9.dist-info}/WHEEL +0 -0
- {floe_python-0.8.7.dist-info → floe_python-0.8.9.dist-info}/entry_points.txt +0 -0
- {floe_python-0.8.7.dist-info → floe_python-0.8.9.dist-info}/licenses/LICENSE +0 -0
floe/__init__.py
CHANGED
|
@@ -136,8 +136,7 @@ def _profile(
|
|
|
136
136
|
profile = parse_profile(path)
|
|
137
137
|
except FloeError as e:
|
|
138
138
|
raise FloeConfigError(f"failed to load profile {_debug(path)}: {e}") from e
|
|
139
|
-
return
|
|
140
|
-
profile.catalogs, profile.storages, profile.lineage) # fmt: skip
|
|
139
|
+
return replace(profile, variables=profile.variables | (variables or {}))
|
|
141
140
|
|
|
142
141
|
|
|
143
142
|
def _load(config_path: str, profile: ProfileConfig | None = None) -> tuple[Path, _Root]:
|
floe/_cli_output.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""`floe run` output: run events as log lines and the run summary (Rust: `crates/floe-cli/src/logging.rs`, `output.rs`)."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import threading
|
|
4
5
|
import time
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
@@ -73,18 +74,20 @@ class Console:
|
|
|
73
74
|
def __init__(self, log_format: str, run_id: str) -> None:
|
|
74
75
|
self.log_format, self.run_id = log_format, run_id
|
|
75
76
|
self.started = False
|
|
76
|
-
self.timings:
|
|
77
|
+
self.timings: dict[
|
|
78
|
+
str, dict[str, int]
|
|
79
|
+
] = {} # entity name -> input -> elapsed_ms
|
|
80
|
+
self._lock = threading.Lock() # entities may run in parallel threads
|
|
77
81
|
|
|
78
82
|
def __call__(self, event: dict[str, Any]) -> None:
|
|
79
83
|
kind = event["event"]
|
|
80
|
-
self.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
self.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
print(fmt(event), flush=True)
|
|
84
|
+
with self._lock:
|
|
85
|
+
self.started |= kind == "run_started"
|
|
86
|
+
if kind == "file_finished":
|
|
87
|
+
self.timings.setdefault(event["entity"], {})[event["input"]] = event["elapsed_ms"] # fmt: skip
|
|
88
|
+
if self.log_format != "off":
|
|
89
|
+
fmt = event_json if self.log_format == "json" else event_text
|
|
90
|
+
print(fmt(event), flush=True)
|
|
88
91
|
|
|
89
92
|
def failed(self, err: FloeError, config: str | None) -> None:
|
|
90
93
|
"""Rust's `emit_failed_run_events`, after a `run_started` for `config` when the run never started.
|
|
@@ -154,7 +157,7 @@ def run_output(
|
|
|
154
157
|
outcome: RunOutcome,
|
|
155
158
|
entities: list[EntityConfig],
|
|
156
159
|
mode: str,
|
|
157
|
-
timings:
|
|
160
|
+
timings: dict[str, dict[str, int]],
|
|
158
161
|
) -> str:
|
|
159
162
|
"""Rust's `format_run_output`; `mode` is `default`, `quiet` or `verbose`."""
|
|
160
163
|
run_dir = outcome.run_dir # a URI on remote storage: join with `/`, never `Path`
|
|
@@ -166,7 +169,17 @@ def run_output(
|
|
|
166
169
|
results = dict.fromkeys(("files_total", "rows_total", "accepted_total", "rejected_total", "files_skipped"), 0) # fmt: skip
|
|
167
170
|
status, exit_code = "success", 0
|
|
168
171
|
else:
|
|
169
|
-
|
|
172
|
+
names = {
|
|
173
|
+
e.id: e.name for e in entities
|
|
174
|
+
} # reports carry the id, `file_finished` events the name
|
|
175
|
+
|
|
176
|
+
def shown(report: dict[str, Any]) -> list[str]:
|
|
177
|
+
ident: str = report["entity"]["name"]
|
|
178
|
+
return _entity(
|
|
179
|
+
report, timings.get(names.get(ident, ident), {}), mode == "verbose"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
blocks = [shown(r) for r in outcome.entity_reports]
|
|
170
183
|
results, run = outcome.summary["results"], outcome.summary["run"]
|
|
171
184
|
status, exit_code = run["status"], run["exit_code"]
|
|
172
185
|
lines = ["DRY RUN MODE - No actual execution performed"] if outcome.dry_run is not None else [] # fmt: skip
|
floe/checks/mismatch.py
CHANGED
|
@@ -119,19 +119,34 @@ def apply_mismatch(df: pl.DataFrame, plan: MismatchOutcome) -> pl.DataFrame:
|
|
|
119
119
|
return df
|
|
120
120
|
|
|
121
121
|
|
|
122
|
-
def
|
|
123
|
-
|
|
122
|
+
def json_probe(
|
|
123
|
+
path: Path, mode: str, limit: int
|
|
124
|
+
) -> tuple[list[str] | None, list[tuple[str, Any]] | None]:
|
|
125
|
+
"""`json_input_columns` plus the file's parsed records when it has at most `limit` (else `None`).
|
|
124
126
|
|
|
125
|
-
|
|
126
|
-
column (Rust 0.7 probed the first object only). `None` for a file without records (D41), which
|
|
127
|
-
differs from records that carry no keys (`[]`): those are still checked for missing columns.
|
|
127
|
+
A file that fits one chunk is then parsed once: the caller reads the records it got here.
|
|
128
128
|
"""
|
|
129
129
|
if mode not in ("array", "ndjson"):
|
|
130
130
|
raise FloeIoError(
|
|
131
131
|
f"unsupported source.options.json_mode={mode} (allowed: array, ndjson)"
|
|
132
132
|
)
|
|
133
|
-
# ponytail: the file is parsed twice (probe, then read); fold the probe into the read if it costs
|
|
134
133
|
keys: set[str] | None = None
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
head: list[tuple[str, Any]] | None = []
|
|
135
|
+
for located in json_records(path, mode):
|
|
136
|
+
keys = keys or set[str]()
|
|
137
|
+
keys.update(str(k) for k in located[1])
|
|
138
|
+
if head is not None and len(head) < limit:
|
|
139
|
+
head.append(located)
|
|
140
|
+
else: # a longer file is read again, chunk by chunk, so it keeps nothing
|
|
141
|
+
head = None
|
|
142
|
+
return (None if keys is None else sorted(keys)), head
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def json_input_columns(path: Path, mode: str = "array") -> list[str] | None:
|
|
146
|
+
"""Sorted top-level keys of every record, read exactly as `read_json` reads them.
|
|
147
|
+
|
|
148
|
+
D40: the union over all records, so a key only a later record carries is still an input
|
|
149
|
+
column (Rust 0.7 probed the first object only). `None` for a file without records (D41), which
|
|
150
|
+
differs from records that carry no keys (`[]`): those are still checked for missing columns.
|
|
151
|
+
"""
|
|
152
|
+
return json_probe(path, mode, 0)[0]
|
floe/checks/unique.py
CHANGED
|
@@ -17,7 +17,6 @@ from floe.checks.rows import RowCheck
|
|
|
17
17
|
|
|
18
18
|
SAMPLE_LIMIT = 5
|
|
19
19
|
_SEEN = "__floe_seen"
|
|
20
|
-
_DEBUG = "__floe_debug"
|
|
21
20
|
_COUNT = "__floe_count"
|
|
22
21
|
_BITS = "_bits" # suffix of the bit-pattern column that keys a float column
|
|
23
22
|
|
|
@@ -56,45 +55,11 @@ def resolve_unique_keys(
|
|
|
56
55
|
return resolved
|
|
57
56
|
|
|
58
57
|
|
|
59
|
-
# `str` Debug; ponytail: non-ASCII non-printable characters (Rust `\\u{..}`) are left raw
|
|
60
|
-
_STR_ESCAPES = {
|
|
61
|
-
"\\": "\\\\",
|
|
62
|
-
'"': '\\"',
|
|
63
|
-
"\n": "\\n",
|
|
64
|
-
"\r": "\\r",
|
|
65
|
-
"\t": "\\t",
|
|
66
|
-
"\0": "\\0",
|
|
67
|
-
**{chr(i): f"\\u{{{i:x}}}" for i in [*range(1, 32), 127] if chr(i) not in "\n\r\t"},
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
58
|
def _float_bits(s: pl.Series) -> pl.Series:
|
|
72
59
|
"""Rust's `to_bits()`: Polars cannot reinterpret a float, so view the Arrow buffer."""
|
|
73
60
|
return pl.Series(s.cast(pl.Float64).to_arrow().view(pa.uint64()))
|
|
74
61
|
|
|
75
62
|
|
|
76
|
-
def _debug(name: str, dtype: pl.DataType) -> pl.Expr:
|
|
77
|
-
"""Rust's `{:?}` of the `UniqueKey` variant: the sample tie-break sorts on these strings."""
|
|
78
|
-
c = pl.col(name)
|
|
79
|
-
if dtype == pl.String:
|
|
80
|
-
escaped = c
|
|
81
|
-
for raw, esc in _STR_ESCAPES.items():
|
|
82
|
-
escaped = escaped.str.replace_all(raw, esc, literal=True)
|
|
83
|
-
return pl.format('String("{}")', escaped)
|
|
84
|
-
if dtype == pl.Boolean:
|
|
85
|
-
return pl.format("Bool({})", c.cast(pl.String))
|
|
86
|
-
if dtype.is_signed_integer():
|
|
87
|
-
return pl.format("I64({})", c.cast(pl.String))
|
|
88
|
-
if dtype.is_unsigned_integer():
|
|
89
|
-
return pl.format("U64({})", c.cast(pl.String))
|
|
90
|
-
if dtype.is_float():
|
|
91
|
-
return pl.format("F64({})", pl.col(name + _BITS).cast(pl.String))
|
|
92
|
-
if isinstance(dtype, pl.Datetime): # chrono prints the fraction only when not zero
|
|
93
|
-
return pl.format('Other("{}")', c.dt.to_string("%Y-%m-%d %H:%M:%S%.f"))
|
|
94
|
-
# ponytail: date keys use `Other(display)`, unverified against Rust; ties may order differently
|
|
95
|
-
return pl.format('Other("{}")', c.cast(pl.String))
|
|
96
|
-
|
|
97
|
-
|
|
98
63
|
def _text(value: object) -> str:
|
|
99
64
|
"""Rust's `to_string` of a key value."""
|
|
100
65
|
if isinstance(value, bool):
|
|
@@ -221,7 +186,7 @@ class UniqueTracker:
|
|
|
221
186
|
return RowCheck("unique", name, message, dup)
|
|
222
187
|
|
|
223
188
|
def results(self) -> list[UniqueResult]:
|
|
224
|
-
"""Per constraint, the top `SAMPLE_LIMIT` keys by count, ties by
|
|
189
|
+
"""Per constraint, the top `SAMPLE_LIMIT` keys by count, ties by the key values (numbers numerically, strings by code point)."""
|
|
225
190
|
return [self._result(c) for c in self._constraints]
|
|
226
191
|
|
|
227
192
|
def _result(self, c: _Constraint) -> UniqueResult:
|
|
@@ -229,14 +194,9 @@ class UniqueTracker:
|
|
|
229
194
|
return UniqueResult(c.columns, 0, [])
|
|
230
195
|
parts = pl.concat(c.dup_counts)
|
|
231
196
|
counts = parts.group_by(parts.columns[:-1]).agg(pl.col(_COUNT).sum())
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
)
|
|
236
|
-
top = (
|
|
237
|
-
counts.with_columns(debug.alias(_DEBUG))
|
|
238
|
-
.sort([_COUNT, _DEBUG], descending=[True, False])
|
|
239
|
-
.head(SAMPLE_LIMIT)
|
|
197
|
+
by = [_COUNT, *counts.columns[:-1]]
|
|
198
|
+
top = counts.sort(by, descending=[True] + [False] * (len(by) - 1)).head(
|
|
199
|
+
SAMPLE_LIMIT
|
|
240
200
|
)
|
|
241
201
|
samples = [ # at most SAMPLE_LIMIT rows
|
|
242
202
|
UniqueSample(
|
floe/config/profile.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from collections.abc import Callable
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import cast
|
|
@@ -395,12 +395,21 @@ def validate_profile(profile: ProfileConfig) -> None:
|
|
|
395
395
|
check_lineage(profile.lineage, "profile.lineage")
|
|
396
396
|
|
|
397
397
|
|
|
398
|
+
def entity_workers(orchestration: Mapping[str, object] | None) -> int:
|
|
399
|
+
"""How many entities a run may run at once: 1 unless `strategy` is `parallel`, then `max_concurrent_entities` or `min(4, cpus)`."""
|
|
400
|
+
if not orchestration or orchestration.get("strategy") != "parallel":
|
|
401
|
+
return 1
|
|
402
|
+
limit = orchestration.get("max_concurrent_entities")
|
|
403
|
+
return limit if isinstance(limit, int) else min(4, os.cpu_count() or 1)
|
|
404
|
+
|
|
405
|
+
|
|
398
406
|
def apply_profile(config: RootConfig, profile: ProfileConfig) -> RootConfig:
|
|
399
407
|
"""The profile's blocks replace the config's, as `apply_profile_catalogs`, `_storages` and `_lineage` do."""
|
|
400
408
|
blocks = {
|
|
401
409
|
"catalogs": profile.catalogs,
|
|
402
410
|
"storages": profile.storages,
|
|
403
411
|
"lineage": profile.lineage,
|
|
412
|
+
"entity_workers": entity_workers(profile.orchestration),
|
|
404
413
|
}
|
|
405
414
|
return config.model_copy(update={k: v for k, v in blocks.items() if v is not None})
|
|
406
415
|
|
floe/config/project.py
CHANGED
floe/iceberg/catalog.py
CHANGED
|
@@ -7,11 +7,16 @@ import tempfile
|
|
|
7
7
|
import threading
|
|
8
8
|
import weakref
|
|
9
9
|
from collections.abc import Generator, Sequence
|
|
10
|
-
from contextlib import contextmanager
|
|
10
|
+
from contextlib import contextmanager, suppress
|
|
11
11
|
from urllib.parse import urlsplit
|
|
12
12
|
|
|
13
13
|
import duckdb
|
|
14
14
|
from pyiceberg.catalog import Catalog, load_catalog
|
|
15
|
+
from pyiceberg.exceptions import (
|
|
16
|
+
NamespaceAlreadyExistsError,
|
|
17
|
+
NoSuchNamespaceError,
|
|
18
|
+
NoSuchTableError,
|
|
19
|
+
)
|
|
15
20
|
from pyiceberg.partitioning import PartitionField, PartitionSpec
|
|
16
21
|
from pyiceberg.schema import Schema
|
|
17
22
|
from pyiceberg.table import Table
|
|
@@ -344,52 +349,59 @@ def ensure_table(
|
|
|
344
349
|
|
|
345
350
|
With `add_columns`, columns new in `schema` are added as optional (D13) and returned by name; partition changes stay rejected.
|
|
346
351
|
"""
|
|
347
|
-
ident = (namespace,)
|
|
348
|
-
with run_error("rest catalog namespace_exists check failed"):
|
|
349
|
-
namespace_exists = catalog.namespace_exists(ident)
|
|
350
|
-
if not namespace_exists:
|
|
351
|
-
if not create_namespace:
|
|
352
|
-
raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
|
|
353
|
-
with run_error("rest catalog namespace create failed"):
|
|
354
|
-
catalog.create_namespace(ident)
|
|
355
352
|
identifier = (namespace, name)
|
|
356
|
-
with run_error("rest catalog
|
|
357
|
-
|
|
358
|
-
if exists:
|
|
359
|
-
with run_error("rest catalog load_table failed"):
|
|
353
|
+
with run_error("rest catalog load_table failed"):
|
|
354
|
+
try:
|
|
360
355
|
table = catalog.load_table(identifier)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
356
|
+
except (
|
|
357
|
+
NoSuchTableError,
|
|
358
|
+
NoSuchNamespaceError,
|
|
359
|
+
): # fmt: skip -- a missing namespace raises either, per catalog
|
|
360
|
+
table = None
|
|
361
|
+
if table is None:
|
|
362
|
+
ident = (namespace,)
|
|
363
|
+
with run_error("rest catalog namespace_exists check failed"):
|
|
364
|
+
namespace_exists = catalog.namespace_exists(ident)
|
|
365
|
+
if not namespace_exists:
|
|
366
|
+
if not create_namespace:
|
|
367
|
+
raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
|
|
368
|
+
with (
|
|
369
|
+
run_error("rest catalog namespace create failed"),
|
|
370
|
+
suppress(NamespaceAlreadyExistsError),
|
|
371
|
+
): # another entity or process created it first
|
|
372
|
+
catalog.create_namespace(ident)
|
|
373
|
+
spec = (
|
|
371
374
|
None
|
|
372
375
|
if partition_fields is None
|
|
373
|
-
else build_partition_spec(
|
|
374
|
-
entity,
|
|
376
|
+
else build_partition_spec(schema, partition_fields, entity)
|
|
375
377
|
)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
378
|
+
with run_error("rest catalog create_table failed"):
|
|
379
|
+
created = catalog.create_table( # pyright: ignore[reportUnknownMemberType] -- PyIceberg's `schema` is `Schema | Unknown`
|
|
380
|
+
identifier,
|
|
381
|
+
schema,
|
|
382
|
+
location=location,
|
|
383
|
+
partition_spec=spec if spec is not None else PartitionSpec(),
|
|
384
|
+
)
|
|
385
|
+
return created, []
|
|
386
|
+
added: list[NestedField] = []
|
|
387
|
+
if schema_evolution == "add_columns":
|
|
388
|
+
added = additive_columns(table.schema(), schema, entity)
|
|
389
|
+
# field ids of `schema` may differ from the table's once columns are added
|
|
390
|
+
check_schema = table.schema()
|
|
391
|
+
else:
|
|
392
|
+
check_schema_matches(table.schema(), schema, entity)
|
|
393
|
+
check_schema = schema
|
|
394
|
+
check_partition_spec_matches(
|
|
395
|
+
table.spec(),
|
|
384
396
|
None
|
|
385
397
|
if partition_fields is None
|
|
386
|
-
else build_partition_spec(
|
|
398
|
+
else build_partition_spec(check_schema, partition_fields, entity),
|
|
399
|
+
entity,
|
|
387
400
|
)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return created, []
|
|
401
|
+
if added:
|
|
402
|
+
with run_error("rest catalog update_schema failed"):
|
|
403
|
+
with table.update_schema() as update:
|
|
404
|
+
for field in added:
|
|
405
|
+
update.add_column(field.name, field.field_type, required=False)
|
|
406
|
+
table.refresh()
|
|
407
|
+
return table, [f.name for f in added]
|
floe/iceberg/sink.py
CHANGED
|
@@ -15,6 +15,7 @@ from typing import Any
|
|
|
15
15
|
import polars as pl
|
|
16
16
|
import pyarrow as pa
|
|
17
17
|
from pyiceberg.catalog import Catalog
|
|
18
|
+
from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchTableError
|
|
18
19
|
from pyiceberg.schema import Schema, assign_fresh_schema_ids
|
|
19
20
|
from pyiceberg.table import Table
|
|
20
21
|
|
|
@@ -39,6 +40,7 @@ from floe.iceberg.merge import merge_scd1, merge_scd2
|
|
|
39
40
|
from floe.iceberg.write import (
|
|
40
41
|
SMALL_FILE_THRESHOLD_BYTES,
|
|
41
42
|
IcebergWriteResult,
|
|
43
|
+
parquet_properties,
|
|
42
44
|
write_table,
|
|
43
45
|
)
|
|
44
46
|
from floe.storage.inputs import InputFile
|
|
@@ -176,13 +178,15 @@ def write_iceberg(
|
|
|
176
178
|
primary_key = entity.schema_.primary_key or [] # validated non-empty for merges
|
|
177
179
|
key = [output.get(k.strip(), k.strip()) for k in primary_key]
|
|
178
180
|
identifier = (target.namespace, target.table)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
exists = entity.sink.write_mode in ("merge_scd1", "merge_scd2") and catalog.table_exists(identifier) # fmt: skip
|
|
182
|
-
if exists:
|
|
181
|
+
# the configured mode: a refreshed merge writes as overwrite and keeps the key guard (#1091)
|
|
182
|
+
if entity.sink.write_mode in ("merge_scd1", "merge_scd2"):
|
|
183
183
|
with run_error("rest catalog load_table failed"):
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
try:
|
|
185
|
+
existing = set(catalog.load_table(identifier).schema().column_names)
|
|
186
|
+
except (NoSuchTableError, NoSuchNamespaceError):
|
|
187
|
+
existing = None
|
|
188
|
+
# Rust `merge/shared.rs`
|
|
189
|
+
if existing is not None and (added := [k for k in key if k not in existing]):
|
|
186
190
|
raise FloeRunError(f"entity.name={entity.name} iceberg schema evolution failed: merge key columns cannot be added: {', '.join(added)}") # fmt: skip
|
|
187
191
|
table, added = ensure_table(
|
|
188
192
|
catalog, target.namespace, target.table, schema, entity.name, target.location.removeprefix("local://"),
|
|
@@ -215,7 +219,7 @@ def write_iceberg(
|
|
|
215
219
|
threshold = SMALL_FILE_THRESHOLD_BYTES if size is None else max(1, size // 2)
|
|
216
220
|
append = "overwrite" if mode == "overwrite" else "append"
|
|
217
221
|
unique = [[output.get(c, c) for c in k] for k in resolve_unique_keys(entity.schema_.unique_keys, [c.name for c in entity.schema_.columns if c.unique], entity.schema_.primary_key)] # fmt: skip
|
|
218
|
-
result = write_table(table, reader, append, run_id, digest, threshold, unique_keys=unique) # fmt: skip
|
|
222
|
+
result = write_table(table, reader, append, run_id, digest, threshold, unique_keys=unique, parquet=parquet_properties(options and options.compression, options and options.row_group_size)) # fmt: skip
|
|
219
223
|
ids = {"table_version": result.metadata_version, "snapshot_id": result.snapshot_id}
|
|
220
224
|
fields = {k: v for k, v in ids.items() if v is not None} | {
|
|
221
225
|
"iceberg_catalog_name": target.catalog.name,
|
floe/iceberg/write.py
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import itertools
|
|
7
7
|
import uuid
|
|
8
|
-
from collections.abc import Callable, Iterator, Sequence
|
|
8
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
9
9
|
from dataclasses import dataclass, replace
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from pathlib import PurePosixPath
|
|
@@ -18,9 +18,17 @@ from pyiceberg.io.pyarrow import (
|
|
|
18
18
|
_check_pyarrow_schema_compatible, # pyright: ignore[reportPrivateUsage] -- same check `Transaction.append` makes
|
|
19
19
|
_dataframe_to_data_files, # pyright: ignore[reportPrivateUsage] -- feeds one snapshot producer from many chunks
|
|
20
20
|
)
|
|
21
|
-
from pyiceberg.manifest import
|
|
21
|
+
from pyiceberg.manifest import (
|
|
22
|
+
DataFile,
|
|
23
|
+
DataFileContent,
|
|
24
|
+
ManifestEntryStatus,
|
|
25
|
+
ManifestFile,
|
|
26
|
+
)
|
|
22
27
|
from pyiceberg.table import Table, TableProperties
|
|
23
|
-
from pyiceberg.table.snapshots import ancestors_of
|
|
28
|
+
from pyiceberg.table.snapshots import Operation, ancestors_of
|
|
29
|
+
from pyiceberg.table.update.snapshot import (
|
|
30
|
+
_OverwriteFiles, # pyright: ignore[reportPrivateUsage] -- PyIceberg 0.12 has no public hook on the manifest list
|
|
31
|
+
)
|
|
24
32
|
|
|
25
33
|
from floe.errors import FloeRunError
|
|
26
34
|
from floe.iceberg.catalog import run_error
|
|
@@ -55,6 +63,14 @@ class IcebergWriteResult:
|
|
|
55
63
|
)
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
class _LiveOverwrite(_OverwriteFiles):
|
|
67
|
+
"""PyIceberg 0.12 keeps the all-DELETED manifest of every earlier overwrite in the manifest list, one more per
|
|
68
|
+
overwrite, so the scan and the commit read more manifests each time (#1132); Java drops them, so do we."""
|
|
69
|
+
|
|
70
|
+
def _existing_manifests(self) -> list[ManifestFile]:
|
|
71
|
+
return [m for m in super()._existing_manifests() if m.has_added_files() or m.has_existing_files()] # fmt: skip
|
|
72
|
+
|
|
73
|
+
|
|
58
74
|
def _chunks(reader: pa.RecordBatchReader, chunk_rows: int) -> Iterator[pa.Table]:
|
|
59
75
|
pending: list[pa.RecordBatch] = []
|
|
60
76
|
rows = 0
|
|
@@ -72,6 +88,16 @@ def _chunks(reader: pa.RecordBatchReader, chunk_rows: int) -> Iterator[pa.Table]
|
|
|
72
88
|
yield pa.Table.from_batches(pending, reader.schema)
|
|
73
89
|
|
|
74
90
|
|
|
91
|
+
def parquet_properties(
|
|
92
|
+
compression: str | None, row_group_size: int | None
|
|
93
|
+
) -> dict[str, str]:
|
|
94
|
+
"""The sink's `compression` and `row_group_size` as PyIceberg's write properties; unset keeps the table's."""
|
|
95
|
+
props = {TableProperties.PARQUET_COMPRESSION: compression} if compression else {}
|
|
96
|
+
if row_group_size:
|
|
97
|
+
props[TableProperties.PARQUET_ROW_GROUP_LIMIT] = str(row_group_size)
|
|
98
|
+
return props
|
|
99
|
+
|
|
100
|
+
|
|
75
101
|
def _metadata_version(location: str | None) -> int | None:
|
|
76
102
|
prefix = PurePosixPath(location or "").name.split("-", 1)[0]
|
|
77
103
|
return int(prefix) if prefix.isdigit() else None
|
|
@@ -86,22 +112,25 @@ def write_table(
|
|
|
86
112
|
small_file_threshold_bytes: int = SMALL_FILE_THRESHOLD_BYTES,
|
|
87
113
|
chunk_rows: int = CHUNK_ROWS,
|
|
88
114
|
unique_keys: Sequence[Sequence[str]] = (),
|
|
115
|
+
parquet: Mapping[str, str] | None = None,
|
|
89
116
|
) -> IcebergWriteResult:
|
|
90
117
|
"""Data files are written chunk by chunk (partitioned tables split each chunk), then committed once.
|
|
91
118
|
|
|
92
119
|
Overwrite replaces every existing file in the same snapshot. An append of no rows commits nothing.
|
|
93
120
|
On a conflict an append checks `unique_keys` (table column names) against the files added since the base
|
|
94
121
|
snapshot and fails closed, committing nothing, when a rival already holds a key (#549).
|
|
122
|
+
`parquet` holds `write.parquet.*` properties for these data files only; the table's own properties are not changed.
|
|
95
123
|
"""
|
|
96
124
|
with run_error("iceberg data write failed"):
|
|
97
125
|
_check_pyarrow_schema_compatible(table.schema(), reader.schema)
|
|
126
|
+
metadata = table.metadata.model_copy(update={"properties": {**table.properties, **(parquet or {})}}) # fmt: skip
|
|
98
127
|
counter = itertools.count()
|
|
99
128
|
write_uuid = uuid.uuid4()
|
|
100
129
|
data_files: list[DataFile] = [
|
|
101
130
|
data_file
|
|
102
131
|
for chunk in _chunks(reader, chunk_rows)
|
|
103
132
|
for data_file in _dataframe_to_data_files(
|
|
104
|
-
|
|
133
|
+
metadata, chunk, table.io, write_uuid, counter
|
|
105
134
|
)
|
|
106
135
|
]
|
|
107
136
|
digest = (
|
|
@@ -131,13 +160,21 @@ def write_table(
|
|
|
131
160
|
# PyIceberg retries a 409 itself (refresh, rebuild) and would skip the guard; in memory only, never sent
|
|
132
161
|
table.metadata = table.metadata.model_copy(update={"properties": {**table.properties, TableProperties.COMMIT_NUM_RETRIES: "0"}}) # fmt: skip
|
|
133
162
|
tx = table.transaction()
|
|
134
|
-
|
|
135
|
-
snapshot_properties={RUN_ID: run_id, INPUT_HASH: digest}
|
|
136
|
-
)
|
|
163
|
+
props = {RUN_ID: run_id, INPUT_HASH: digest}
|
|
137
164
|
with (
|
|
138
|
-
|
|
165
|
+
_LiveOverwrite(
|
|
166
|
+
operation=Operation.OVERWRITE
|
|
167
|
+
if table.current_snapshot()
|
|
168
|
+
else Operation.APPEND,
|
|
169
|
+
transaction=tx,
|
|
170
|
+
io=table.io,
|
|
171
|
+
branch="main",
|
|
172
|
+
snapshot_properties=props,
|
|
173
|
+
)
|
|
139
174
|
if mode == "overwrite"
|
|
140
|
-
else
|
|
175
|
+
else tx.update_snapshot(
|
|
176
|
+
snapshot_properties=props
|
|
177
|
+
).fast_append() as snap
|
|
141
178
|
):
|
|
142
179
|
for old in replaced:
|
|
143
180
|
snap.delete_data_file(old)
|
floe/lineage/observer.py
CHANGED
|
@@ -110,7 +110,6 @@ def _warn(message: str) -> None:
|
|
|
110
110
|
class OpenLineageObserver:
|
|
111
111
|
"""A run observer (`floe.run.events.Observer`) posting OpenLineage `RunEvent`s."""
|
|
112
112
|
|
|
113
|
-
# ponytail: unsynchronised breaker counters; add a lock when entities run in parallel.
|
|
114
113
|
def __init__(
|
|
115
114
|
self, config: LineageConfig, entities: Sequence[EntityConfig], config_path: str
|
|
116
115
|
) -> None:
|
|
@@ -127,12 +126,14 @@ class OpenLineageObserver:
|
|
|
127
126
|
self.run_uuid: str | None = None
|
|
128
127
|
self.consecutive_failures = 0
|
|
129
128
|
self.circuit_open = False
|
|
129
|
+
self._lock = threading.Lock() # entities may run in parallel threads
|
|
130
130
|
|
|
131
131
|
def __call__(self, event: dict[str, Any]) -> None:
|
|
132
132
|
kind, ts = event["event"], event["ts_ms"]
|
|
133
133
|
job = self.events.run_job_name
|
|
134
134
|
if kind == "run_started":
|
|
135
|
-
|
|
135
|
+
with self._lock:
|
|
136
|
+
self.consecutive_failures, self.circuit_open = 0, False
|
|
136
137
|
self.run_uuid = str(uuid.uuid4())
|
|
137
138
|
self._post("START", ts, self.run_uuid, job)
|
|
138
139
|
elif kind == "entity_started":
|
|
@@ -171,19 +172,19 @@ class OpenLineageObserver:
|
|
|
171
172
|
time.sleep(delay)
|
|
172
173
|
retryable = self._attempt(data)
|
|
173
174
|
if retryable is None:
|
|
174
|
-
self.
|
|
175
|
+
with self._lock:
|
|
176
|
+
self.consecutive_failures = 0
|
|
175
177
|
return
|
|
176
178
|
if not retryable:
|
|
177
179
|
break
|
|
178
180
|
_warn(f"POST {self.url} failed — event dropped")
|
|
179
|
-
self.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
self.circuit_open = True
|
|
181
|
+
with self._lock:
|
|
182
|
+
self.consecutive_failures += 1
|
|
183
|
+
failures = self.consecutive_failures
|
|
184
|
+
limit = 3 if self.config.max_failures is None else self.config.max_failures
|
|
185
|
+
opening = failures >= limit and not self.circuit_open
|
|
186
|
+
self.circuit_open |= opening
|
|
187
|
+
if opening:
|
|
187
188
|
_warn(f"disabled for this run after {failures} consecutive failures — check endpoint {self.url}") # fmt: skip
|
|
188
189
|
|
|
189
190
|
def _attempt(self, data: bytes) -> bool | None:
|
|
@@ -251,6 +252,7 @@ def run_with_lineage(
|
|
|
251
252
|
return run(
|
|
252
253
|
path, root, run_id=run_id, entities=entities, dry_run=dry_run,
|
|
253
254
|
full_refresh=full_refresh, observer=observe, config_uri=config_uri,
|
|
255
|
+
workers=root.entity_workers,
|
|
254
256
|
) # fmt: skip
|
|
255
257
|
except FloeError:
|
|
256
258
|
if lineage:
|
floe/manifest_run.py
CHANGED
|
@@ -12,6 +12,7 @@ from typing import Any, TypeVar, cast, get_args
|
|
|
12
12
|
|
|
13
13
|
from pydantic import BaseModel, ValidationError
|
|
14
14
|
|
|
15
|
+
from floe.config.profile import entity_workers
|
|
15
16
|
from floe.config.project import (
|
|
16
17
|
LineageConfig,
|
|
17
18
|
ProjectMetadata,
|
|
@@ -265,6 +266,9 @@ def load_manifest(location: str | Path) -> tuple[RootConfig, list[EntityConfig],
|
|
|
265
266
|
spec = m["spec_version"]
|
|
266
267
|
entities = [_entity(e, _named_local(m)) for e in m["entities"]]
|
|
267
268
|
report_base = m["report_base_uri"]
|
|
269
|
+
workers = entity_workers(
|
|
270
|
+
cast(Json, m.get("execution") or {}).get("orchestration")
|
|
271
|
+
)
|
|
268
272
|
meta = m.get("metadata") # D20: dumped without its None fields
|
|
269
273
|
metadata = None if meta is None else ProjectMetadata.model_validate(_omitted_as_none(ProjectMetadata, meta)) # fmt: skip
|
|
270
274
|
except (ValueError, KeyError, TypeError, AttributeError) as e:
|
|
@@ -289,6 +293,7 @@ def load_manifest(location: str | Path) -> tuple[RootConfig, list[EntityConfig],
|
|
|
289
293
|
lineage=_section(LineageConfig, m, "lineage"),
|
|
290
294
|
entities=entities,
|
|
291
295
|
config_files=[],
|
|
296
|
+
entity_workers=workers,
|
|
292
297
|
)
|
|
293
298
|
observed = [_lineage_entity(e, me) for e, me in zip(entities, m["entities"])]
|
|
294
299
|
return root, observed, _replay_base(location, m)
|
floe/read/json.py
CHANGED
|
@@ -15,6 +15,11 @@ from floe.config.types import ColumnConfig, SourceOptions
|
|
|
15
15
|
from floe.errors import FloeIoError
|
|
16
16
|
|
|
17
17
|
Selector = list[str | int] # a field name or an array index
|
|
18
|
+
JSON_CHUNK_ROWS = 1_000_000
|
|
19
|
+
# ponytail: a file of at most this many bytes is parsed once, its dicts held until read (about 16x its size
|
|
20
|
+
# in memory, so 8 MiB costs about 130 MiB); larger files parse twice (key pass, then chunks).
|
|
21
|
+
# Upgrade path: build the first chunk's rows while probing.
|
|
22
|
+
JSON_SINGLE_PASS_BYTES = 8 * 1024 * 1024
|
|
18
23
|
|
|
19
24
|
|
|
20
25
|
@dataclass(frozen=True)
|
|
@@ -188,13 +193,15 @@ def read_json(
|
|
|
188
193
|
options: SourceOptions,
|
|
189
194
|
columns: Sequence[ColumnConfig],
|
|
190
195
|
cast_mode: str = "strict",
|
|
191
|
-
chunk_rows: int =
|
|
196
|
+
chunk_rows: int = JSON_CHUNK_ROWS,
|
|
197
|
+
records: Sequence[tuple[str, Any]] | None = None,
|
|
192
198
|
) -> Iterator[JsonChunk]:
|
|
193
199
|
"""Yield the rows in chunks of at most `chunk_rows`, columns named by selector; always at least one.
|
|
194
200
|
|
|
195
201
|
`options.json_mode` is `array` (default) or `ndjson`. A selector that finds nothing is null.
|
|
196
202
|
Objects and arrays are an error unless `cast_mode` is `coerce`, which keeps their compact JSON.
|
|
197
203
|
Scalars are stringified with Python's `str`; Polars would render `1.0` as `1`, so it is not used.
|
|
204
|
+
`records` are the file's already parsed `json_records`, read instead of parsing the file again.
|
|
198
205
|
"""
|
|
199
206
|
mode = options.json_mode or "array"
|
|
200
207
|
if mode not in ("array", "ndjson"):
|
|
@@ -221,7 +228,7 @@ def read_json(
|
|
|
221
228
|
exact = [decimal_type(c.column_type) is not None for c in columns]
|
|
222
229
|
rows: list[list[str | None]] = []
|
|
223
230
|
done = 0
|
|
224
|
-
for where, value in json_records(path, mode):
|
|
231
|
+
for where, value in json_records(path, mode) if records is None else records:
|
|
225
232
|
rows.append(
|
|
226
233
|
[
|
|
227
234
|
_cell(value, p, n, cast_mode, where, e)
|
floe/run/__init__.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
"""A run: entity selection, run context,
|
|
1
|
+
"""A run: entity selection, run context, entities in config order or in parallel threads (`workers` > 1), dry run, full refresh, events (Rust: `run/mod.rs`, `run/context.rs`)."""
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
4
|
from collections.abc import Sequence
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from datetime import UTC, datetime
|
|
7
8
|
from pathlib import Path
|
|
@@ -23,6 +24,7 @@ from floe.report.summary import (
|
|
|
23
24
|
)
|
|
24
25
|
from floe.run.entity import EntityRun, list_source, resolve_source, run_entity
|
|
25
26
|
from floe.run.events import Emitter, Observer
|
|
27
|
+
from floe.run.parallel import exclusive_keys, run_parallel
|
|
26
28
|
from floe.storage.inputs import ResolvedInputs
|
|
27
29
|
from floe.storage.paths import normalize_local_path
|
|
28
30
|
from floe.storage.resolve import clear_storage_clients, resolve_path, storage_client
|
|
@@ -103,8 +105,12 @@ def run(
|
|
|
103
105
|
full_refresh: bool = False,
|
|
104
106
|
observer: Observer | None = None,
|
|
105
107
|
config_uri: str | None = None,
|
|
108
|
+
workers: int = 1,
|
|
106
109
|
) -> RunOutcome:
|
|
107
|
-
"""Run the selected entities
|
|
110
|
+
"""Run the selected entities, writing each entity's report as it finishes. A failing entity raises: no summary, as Rust.
|
|
111
|
+
|
|
112
|
+
One worker runs them in config order. More run them in parallel threads (observers are called from those threads), never two on the same target; reports and summary stay in config order.
|
|
113
|
+
"""
|
|
108
114
|
clear_storage_clients()
|
|
109
115
|
started, timer = _rfc3339(), time.monotonic()
|
|
110
116
|
clear_catalogs()
|
|
@@ -153,20 +159,30 @@ def run(
|
|
|
153
159
|
|
|
154
160
|
emit("run_started", config=config, report_base=report_path)
|
|
155
161
|
# Rust resolves every entity's inputs before the first entity_started (#864); run_entity reuses them
|
|
156
|
-
|
|
157
|
-
for e in selected:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
# D24: a full refresh may rebuild from the archive alone, so run_entity decides
|
|
163
|
+
to_list = [e for e in selected if not (full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive"))] # fmt: skip
|
|
164
|
+
# `map` yields in config order, so the first failure in config order is raised
|
|
165
|
+
if workers > 1 and len(to_list) > 1:
|
|
166
|
+
with ThreadPoolExecutor(workers, thread_name_prefix="floe-list") as pool:
|
|
167
|
+
found = list(pool.map(inputs, to_list))
|
|
168
|
+
else:
|
|
169
|
+
found = [inputs(e) for e in to_list]
|
|
170
|
+
listed = {e.id: f for e, f in zip(to_list, found, strict=True)}
|
|
163
171
|
client = storage_client(report_storage)
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
|
|
173
|
+
def finish(e: EntityConfig) -> dict[str, Any]:
|
|
166
174
|
report = one(e).report
|
|
167
|
-
reports.append((id_path(e), report))
|
|
168
175
|
write_entity_report(run_path, id_path(e), report, client)
|
|
169
176
|
_finished(emit, e.id, report)
|
|
177
|
+
return report
|
|
178
|
+
|
|
179
|
+
# D36: a policy abort fails only its entity; a FloeError still stops the run
|
|
180
|
+
if workers > 1 and len(selected) > 1:
|
|
181
|
+
keys = [exclusive_keys(e, config_dir, root.storages, root.catalogs) for e in selected] # fmt: skip
|
|
182
|
+
done = run_parallel(selected, keys, workers, finish)
|
|
183
|
+
else:
|
|
184
|
+
done = [finish(e) for e in selected]
|
|
185
|
+
reports = [(id_path(e), r) for e, r in zip(selected, done, strict=True)]
|
|
170
186
|
summary = build_summary(
|
|
171
187
|
root.version, reports,
|
|
172
188
|
run_id=run_id, started_at=started, finished_at=_rfc3339(),
|
floe/run/entity.py
CHANGED
|
@@ -21,7 +21,7 @@ from floe.checks.mismatch import (
|
|
|
21
21
|
MismatchOutcome,
|
|
22
22
|
apply_mismatch,
|
|
23
23
|
declared_names,
|
|
24
|
-
|
|
24
|
+
json_probe,
|
|
25
25
|
plan_mismatch,
|
|
26
26
|
)
|
|
27
27
|
from floe.checks.normalize import (
|
|
@@ -59,7 +59,7 @@ from floe.iceberg.write import find_committed
|
|
|
59
59
|
from floe.pii import apply_pii_masking
|
|
60
60
|
from floe.read.avro import read_avro
|
|
61
61
|
from floe.read.csv import RAGGED_MESSAGE, read_csv
|
|
62
|
-
from floe.read.json import read_json
|
|
62
|
+
from floe.read.json import JSON_CHUNK_ROWS, JSON_SINGLE_PASS_BYTES, read_json
|
|
63
63
|
from floe.read.orc import read_orc
|
|
64
64
|
from floe.read.parquet import read_parquet
|
|
65
65
|
from floe.read.xlsx import read_xlsx
|
|
@@ -118,7 +118,7 @@ class EntityRun:
|
|
|
118
118
|
aborted: bool
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
def _chunks(entity: EntityConfig, path: Path, names: list[str]) -> Chunks:
|
|
121
|
+
def _chunks(entity: EntityConfig, path: Path, names: list[str], head: list[tuple[str, Any]] | None = None) -> Chunks: # fmt: skip
|
|
122
122
|
src = entity.source
|
|
123
123
|
match src.format:
|
|
124
124
|
case "csv" | "tsv":
|
|
@@ -139,7 +139,7 @@ def _chunks(entity: EntityConfig, path: Path, names: list[str]) -> Chunks:
|
|
|
139
139
|
for x in read_xml(path, src.options, entity.schema_.columns[: len(names)]):
|
|
140
140
|
yield x.offset, x.df, None
|
|
141
141
|
case "json": # #998
|
|
142
|
-
for j in read_json(path, src.options, entity.schema_.columns[: len(names)], src.cast_mode): # fmt: skip
|
|
142
|
+
for j in read_json(path, src.options, entity.schema_.columns[: len(names)], src.cast_mode, JSON_CHUNK_ROWS, head): # fmt: skip
|
|
143
143
|
yield j.offset, j.df, None
|
|
144
144
|
case other:
|
|
145
145
|
raise FloeRunError(
|
|
@@ -402,7 +402,8 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
|
|
|
402
402
|
def failed(err: FloeIoError, mismatch: MismatchOutcome | None) -> tuple[dict[str, Any], Iterable[pl.DataFrame]]: # fmt: skip
|
|
403
403
|
# Rust skips the raw copy under PII: the file cannot be masked
|
|
404
404
|
plan.tracker.restore(keys) # the unreadable file leaves no keys behind
|
|
405
|
-
|
|
405
|
+
# a file removed after listing has nothing to copy: the read error alone is reported
|
|
406
|
+
keep = entity.pii is None and entity.sink.rejected is not None and path.exists()
|
|
406
407
|
raw = _copy_raw(plan, path, severity, file.source_name) if keep else None
|
|
407
408
|
return _failed_file(entity, uri, len(declared) + len(derived), mismatch, err, raw), [] # fmt: skip
|
|
408
409
|
|
|
@@ -411,7 +412,9 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
|
|
|
411
412
|
first, probe = [], xml_columns(path, entity.source.options)
|
|
412
413
|
elif entity.source.format == "json":
|
|
413
414
|
# D40: every record's top-level keys; D41: only a file without records has nothing to compare
|
|
414
|
-
|
|
415
|
+
# a file listed as small is parsed once: the read reuses the probe's records (limit 0 keeps none)
|
|
416
|
+
json_keys, head = json_probe(path, entity.source.options.json_mode or "array", JSON_CHUNK_ROWS if file.source_size is not None and file.source_size <= JSON_SINGLE_PASS_BYTES else 0) # fmt: skip
|
|
417
|
+
chunks = _chunks(entity, path, file_names, head)
|
|
415
418
|
first, probe = [], declared if json_keys is None else json_keys
|
|
416
419
|
else: # the first chunk's columns are the input columns the plan compares
|
|
417
420
|
first = [next(chunks)]
|
floe/run/parallel.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Entities in parallel threads (#1105): the schedule keeps entities that share a resource apart and the failure rule of D36."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from floe.config.storage import CatalogsConfig, StoragesConfig
|
|
9
|
+
from floe.config.types import EntityConfig
|
|
10
|
+
from floe.errors import FloeError
|
|
11
|
+
from floe.iceberg.sink import resolve_target
|
|
12
|
+
from floe.run.entity import state_uri
|
|
13
|
+
from floe.storage.resolve import resolve_path
|
|
14
|
+
|
|
15
|
+
MERGE = "merge" # every merge entity shares this key: DuckDB memory, and Polaris conflicts on one table
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _canonical(uri: str) -> str:
|
|
19
|
+
"""One spelling per destination: a local path resolved through symlinks, a remote URI without a trailing `/`."""
|
|
20
|
+
if uri.startswith("local://"):
|
|
21
|
+
return f"local://{Path(uri.removeprefix('local://')).resolve()}"
|
|
22
|
+
return uri.rstrip("/")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def exclusive_keys(e: EntityConfig, config_dir: Path, storages: StoragesConfig | None, catalogs: CatalogsConfig | None) -> frozenset[str]: # fmt: skip
|
|
26
|
+
"""What `e` writes that no other running entity may touch: its accepted path, Iceberg table, state file, and the merge slot."""
|
|
27
|
+
sink = e.sink
|
|
28
|
+
try:
|
|
29
|
+
keys = {_canonical(resolve_path(storages, config_dir, sink.accepted.storage, sink.accepted.path, f"entity.name={e.name} sink.accepted.storage")[0])} # fmt: skip
|
|
30
|
+
if sink.accepted.format == "iceberg":
|
|
31
|
+
t = resolve_target(storages, catalogs, config_dir, e)
|
|
32
|
+
keys.add(f"iceberg://{t.catalog.name}/{t.namespace}/{t.table}")
|
|
33
|
+
if e.incremental_mode == "file":
|
|
34
|
+
keys.add(_canonical(state_uri(e, config_dir, storages)[0]))
|
|
35
|
+
except FloeError:
|
|
36
|
+
return frozenset() # `run_entity` raises the same error when it runs
|
|
37
|
+
if sink.write_mode.startswith("merge"):
|
|
38
|
+
keys.add(MERGE)
|
|
39
|
+
return frozenset(keys)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_parallel(entities: Sequence[EntityConfig], keys: Sequence[frozenset[str]], workers: int, task: Callable[[EntityConfig], dict[str, Any]]) -> list[dict[str, Any]]: # fmt: skip
|
|
43
|
+
"""Run `task` over `entities` on `workers` threads, never two with a key in common; the reports in `entities` order.
|
|
44
|
+
|
|
45
|
+
After a failure no entity starts, the running ones finish (and release their claims), and the first error in `entities` order is raised.
|
|
46
|
+
"""
|
|
47
|
+
reports: dict[int, dict[str, Any]] = {}
|
|
48
|
+
errors: dict[int, BaseException] = {}
|
|
49
|
+
pending = list(range(len(entities)))
|
|
50
|
+
running: dict[Future[dict[str, Any]], int] = {}
|
|
51
|
+
busy: set[str] = set()
|
|
52
|
+
with ThreadPoolExecutor(workers, thread_name_prefix="floe-entity") as pool:
|
|
53
|
+
while pending or running:
|
|
54
|
+
for i in list(pending):
|
|
55
|
+
if len(running) == workers:
|
|
56
|
+
break
|
|
57
|
+
if not keys[i] & busy:
|
|
58
|
+
pending.remove(i)
|
|
59
|
+
busy |= keys[i]
|
|
60
|
+
running[pool.submit(task, entities[i])] = i
|
|
61
|
+
for future in wait(running, return_when="FIRST_COMPLETED").done:
|
|
62
|
+
i = running.pop(future)
|
|
63
|
+
busy -= keys[i]
|
|
64
|
+
if error := future.exception():
|
|
65
|
+
errors[i] = error
|
|
66
|
+
pending.clear()
|
|
67
|
+
else:
|
|
68
|
+
reports[i] = future.result()
|
|
69
|
+
if errors:
|
|
70
|
+
raise errors[min(errors)]
|
|
71
|
+
return [reports[i] for i in range(len(entities))]
|
|
@@ -1,53 +1,54 @@
|
|
|
1
|
-
floe/__init__.py,sha256=
|
|
1
|
+
floe/__init__.py,sha256=jEhTn9_w_xbOK0QPixbRMOBbZ2MiGAz2oW4I9n3z6_s,13254
|
|
2
2
|
floe/__main__.py,sha256=Nzz6wrGXRpJiO59qlX7WEJRTlb-XcRFswmP0TA2KjyM,57
|
|
3
3
|
floe/_cli.py,sha256=hwast3uMX0iUtkIpcChSIVHYkoYi4fR4jwCwQT53lXE,14101
|
|
4
|
-
floe/_cli_output.py,sha256=
|
|
4
|
+
floe/_cli_output.py,sha256=6DBAR3cNW2kFW_QGkwP1PpVC8AbvOjQW1LMaGnmC3qs,9232
|
|
5
5
|
floe/add_entity.py,sha256=42b7l8o55dzgaR_DDgpW7YWdnU2weYMf4U_rf7qCY6M,21986
|
|
6
6
|
floe/errors.py,sha256=hLSkgPQATBnjY1CAQqX4aB0oI0-WKoHW09bDXAgzC7k,186
|
|
7
7
|
floe/glob.py,sha256=A0HdePLXLGl1T6heJbgdrNHr8pZfdidG7pQ1QsXWOl8,10579
|
|
8
8
|
floe/maintain.py,sha256=CGSoZhO7p0u14o10FCKCO4FprzpVO548A71eq5FowF8,7343
|
|
9
9
|
floe/manifest.py,sha256=sAWjv6yS4GokQDTnZwutyplLz1ICPBYl2qJmm9IORdM,20462
|
|
10
|
-
floe/manifest_run.py,sha256=
|
|
10
|
+
floe/manifest_run.py,sha256=8rkHX2RuxHyp02NI2NLBsU6xq6m5yebR0eoYETV_7Tw,12591
|
|
11
11
|
floe/pii.py,sha256=uJjU-C4c7h45E7rEL9K3Zp1LTTLh-FNKfUV5av2QMO4,4351
|
|
12
12
|
floe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
13
|
floe/state.py,sha256=XFHF6zVxbdGg1bkONZDLryTD4noDF9IgaimaEI-3Nlg,11104
|
|
14
14
|
floe/checks/cast.py,sha256=lM40xtKEggWI8FW3jijM3T2JFQtaXs8FhsFXo3vy5oM,6788
|
|
15
|
-
floe/checks/mismatch.py,sha256=
|
|
15
|
+
floe/checks/mismatch.py,sha256=RNqRL1nihH6Y83RI-dxPXf0WTWZccXq6mnR4m6V0IIo,5827
|
|
16
16
|
floe/checks/normalize.py,sha256=9fdRYFjWGXBAEw1Po71N5tsUWG8eekmSWIQktPduOig,4431
|
|
17
17
|
floe/checks/rows.py,sha256=-yPieSus0-7ZewLv2vR8waHqQ9DGMbVvmJh1_hK4eDs,4433
|
|
18
|
-
floe/checks/unique.py,sha256=
|
|
18
|
+
floe/checks/unique.py,sha256=ZVckMOoGuKIzq3M84TGeZ1ks0nab9YTaI54w5Q_rjZc,10864
|
|
19
19
|
floe/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
20
|
floe/config/entity.py,sha256=CLXLXzkUbQhxenD_LwjBt0eoPreaYaxiBVEsdmnFIco,15883
|
|
21
21
|
floe/config/include.py,sha256=XKYhp2QK_VUmAODAXJ35Wl4FCpQEMQm24m-WKlpI_og,5033
|
|
22
22
|
floe/config/parse.py,sha256=B3gOi80-M0y9Y4OyNjM4MYmM2mFHKGVpqipX1JqaLjM,5290
|
|
23
|
-
floe/config/profile.py,sha256=
|
|
24
|
-
floe/config/project.py,sha256=
|
|
23
|
+
floe/config/profile.py,sha256=4imKVdZ-Ehbj71OA3K4PrShE-gOYuicMDnTD-QHIkA0,16552
|
|
24
|
+
floe/config/project.py,sha256=hUsnjbNAEiiy_iVJYA1IDquZ4OY4OF7JpNaEVJ_ocCg,14221
|
|
25
25
|
floe/config/storage.py,sha256=BK3P65ukFqNgmbjCcuYmXR04OScf1yD8f19vGJq-aDI,6234
|
|
26
26
|
floe/config/types.py,sha256=4eveoOlyGmNxyZpI1PSjli9lnXLPc1-jYj4fL8qHN_8,5038
|
|
27
27
|
floe/config/validate.py,sha256=7tyofwoTBL-BBCNxnmkXeSOybwmMNngeEcj8zviRyLs,36176
|
|
28
28
|
floe/config/vars.py,sha256=7Bq0IU0KrFeRlyA8W9-F0gDSDKfXCfFpBrX08H8Flvw,4577
|
|
29
29
|
floe/config/yaml.py,sha256=HeFGhKFxYkiqQOwljYOgUD5naJyxL_ZIX6hUIS36tzU,10071
|
|
30
|
-
floe/iceberg/catalog.py,sha256=
|
|
30
|
+
floe/iceberg/catalog.py,sha256=qEhMvH4NFBd4GxqBEg4jqgTNxtqWkL_RRzIlgXr2GmA,16722
|
|
31
31
|
floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
|
|
32
32
|
floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
|
|
33
|
-
floe/iceberg/sink.py,sha256=
|
|
33
|
+
floe/iceberg/sink.py,sha256=vdkgLjqvWH7opOrfc-147xCMQhLnxRjUzLjHLchHlAM,12043
|
|
34
34
|
floe/iceberg/unique.py,sha256=OmiPQthhB5jGTaXsCu9ZztNFEFUf6eT0MLU2Gnh-C2A,1095
|
|
35
|
-
floe/iceberg/write.py,sha256=
|
|
35
|
+
floe/iceberg/write.py,sha256=YNE8IwLV9jK3vksO0XCj36HKHqW6ZEHCu4173Xpi6hg,12854
|
|
36
36
|
floe/lineage/events.py,sha256=4txd8bGUwKBZd-8Aw_ebTurhlxAYk8SVMgH8vSjC3bs,6682
|
|
37
|
-
floe/lineage/observer.py,sha256=
|
|
37
|
+
floe/lineage/observer.py,sha256=IFHjzsT65L-NkcQ8Noa_8CDVpLu4iGadRB2-K0Lj8t8,10216
|
|
38
38
|
floe/read/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
39
39
|
floe/read/avro.py,sha256=rhGYlrWGFnqt_tyLIph57qV2TH91ULjoJ5qUlpW_6jI,1419
|
|
40
40
|
floe/read/csv.py,sha256=1lNLh6MGrInWi3TADERA--rOG1iOVcgGdo7JPtt5LdI,5237
|
|
41
|
-
floe/read/json.py,sha256=
|
|
41
|
+
floe/read/json.py,sha256=3wU24pKYIhheY7DRIjyb59NA4lQRPjRisXpsMH8cfdY,8807
|
|
42
42
|
floe/read/orc.py,sha256=TWgdlLBR2NF_oVZ5zEmRKNXoTkq5QfF2BOFzKyzSjhY,1553
|
|
43
43
|
floe/read/parquet.py,sha256=JwfuWaHj2TxBKJeaKignq8p0Op_pUfsOdVh7dDC3wcE,1491
|
|
44
44
|
floe/read/xlsx.py,sha256=dengSY8HuNh9ahyR_LR8beqXTZQubqQ1Jx8qEak5jrY,3009
|
|
45
45
|
floe/read/xml.py,sha256=7m6DtoU8iExuLROavyojysqOUE_Bd_N0uwFaLAzjI_Y,8004
|
|
46
46
|
floe/report/entity.py,sha256=FRwLtXrlJXDNXrOKscitm-NCNJWQXd0vr_JIf_cUp20,9867
|
|
47
47
|
floe/report/summary.py,sha256=tb547VRkM5WGWHQ-4vZB2DUW4VTWgv9FUoym-NpuWE4,4452
|
|
48
|
-
floe/run/__init__.py,sha256=
|
|
49
|
-
floe/run/entity.py,sha256=
|
|
48
|
+
floe/run/__init__.py,sha256=yCLCoLbtA_Iuy7wabkFSImeuU8xrLp8Q0ZgYmw8WDRg,9229
|
|
49
|
+
floe/run/entity.py,sha256=2NxCKNVUn0v2Oat4FslZfNjWAu3YhGKnSks3NRwkexU,49764
|
|
50
50
|
floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
|
|
51
|
+
floe/run/parallel.py,sha256=lKBW9NOpOu6KFOcEo-RQ56ukEOKHXPPoLI3o6gmlY_g,3435
|
|
51
52
|
floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
52
53
|
floe/storage/archive.py,sha256=1gPImm9PDSJFCpWjg37ax4ubb0wwc6FZm38quQXhORg,1101
|
|
53
54
|
floe/storage/inputs.py,sha256=nmgEo2ub3hPUghLUXHU1wnKzpL4aYZbHb6S0XPYvMew,10410
|
|
@@ -60,8 +61,8 @@ floe/storage/s3.py,sha256=XcG1qJTEY3KLSMLT950mUdrAxuCA8-Gx3fpNSzyV_Xo,7508
|
|
|
60
61
|
floe/storage/types.py,sha256=FvAEXWMdVdopRK276JyETRYGOva2-uR8pZvsWj6Xvy0,993
|
|
61
62
|
floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
62
63
|
floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
|
|
63
|
-
floe_python-0.8.
|
|
64
|
-
floe_python-0.8.
|
|
65
|
-
floe_python-0.8.
|
|
66
|
-
floe_python-0.8.
|
|
67
|
-
floe_python-0.8.
|
|
64
|
+
floe_python-0.8.9.dist-info/METADATA,sha256=jwfiIw93AklPynvV_-ABBNgnNCZjBArFMK4APsKu9Bk,442
|
|
65
|
+
floe_python-0.8.9.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
66
|
+
floe_python-0.8.9.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
|
|
67
|
+
floe_python-0.8.9.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
|
|
68
|
+
floe_python-0.8.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|