floe-python 0.8.7__py3-none-any.whl → 0.8.8__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/config/profile.py +10 -1
- floe/config/project.py +1 -0
- floe/iceberg/catalog.py +6 -2
- floe/lineage/observer.py +13 -11
- floe/manifest_run.py +5 -0
- floe/read/json.py +9 -2
- floe/run/__init__.py +18 -6
- floe/run/entity.py +7 -5
- floe/run/parallel.py +71 -0
- {floe_python-0.8.7.dist-info → floe_python-0.8.8.dist-info}/METADATA +1 -1
- {floe_python-0.8.7.dist-info → floe_python-0.8.8.dist-info}/RECORD +17 -16
- {floe_python-0.8.7.dist-info → floe_python-0.8.8.dist-info}/WHEEL +0 -0
- {floe_python-0.8.7.dist-info → floe_python-0.8.8.dist-info}/entry_points.txt +0 -0
- {floe_python-0.8.7.dist-info → floe_python-0.8.8.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/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,12 @@ 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 NamespaceAlreadyExistsError
|
|
15
16
|
from pyiceberg.partitioning import PartitionField, PartitionSpec
|
|
16
17
|
from pyiceberg.schema import Schema
|
|
17
18
|
from pyiceberg.table import Table
|
|
@@ -350,7 +351,10 @@ def ensure_table(
|
|
|
350
351
|
if not namespace_exists:
|
|
351
352
|
if not create_namespace:
|
|
352
353
|
raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
|
|
353
|
-
with
|
|
354
|
+
with (
|
|
355
|
+
run_error("rest catalog namespace create failed"),
|
|
356
|
+
suppress(NamespaceAlreadyExistsError),
|
|
357
|
+
): # another entity or process created it first
|
|
354
358
|
catalog.create_namespace(ident)
|
|
355
359
|
identifier = (namespace, name)
|
|
356
360
|
with run_error("rest catalog table_exists check failed"):
|
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,4 +1,4 @@
|
|
|
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
|
|
@@ -23,6 +23,7 @@ from floe.report.summary import (
|
|
|
23
23
|
)
|
|
24
24
|
from floe.run.entity import EntityRun, list_source, resolve_source, run_entity
|
|
25
25
|
from floe.run.events import Emitter, Observer
|
|
26
|
+
from floe.run.parallel import exclusive_keys, run_parallel
|
|
26
27
|
from floe.storage.inputs import ResolvedInputs
|
|
27
28
|
from floe.storage.paths import normalize_local_path
|
|
28
29
|
from floe.storage.resolve import clear_storage_clients, resolve_path, storage_client
|
|
@@ -103,8 +104,12 @@ def run(
|
|
|
103
104
|
full_refresh: bool = False,
|
|
104
105
|
observer: Observer | None = None,
|
|
105
106
|
config_uri: str | None = None,
|
|
107
|
+
workers: int = 1,
|
|
106
108
|
) -> RunOutcome:
|
|
107
|
-
"""Run the selected entities
|
|
109
|
+
"""Run the selected entities, writing each entity's report as it finishes. A failing entity raises: no summary, as Rust.
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
"""
|
|
108
113
|
clear_storage_clients()
|
|
109
114
|
started, timer = _rfc3339(), time.monotonic()
|
|
110
115
|
clear_catalogs()
|
|
@@ -159,14 +164,21 @@ def run(
|
|
|
159
164
|
archive = full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive") # fmt: skip
|
|
160
165
|
if not archive:
|
|
161
166
|
listed[e.id] = inputs(e)
|
|
162
|
-
reports: list[tuple[str, dict[str, Any]]] = []
|
|
163
167
|
client = storage_client(report_storage)
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
|
|
169
|
+
def finish(e: EntityConfig) -> dict[str, Any]:
|
|
166
170
|
report = one(e).report
|
|
167
|
-
reports.append((id_path(e), report))
|
|
168
171
|
write_entity_report(run_path, id_path(e), report, client)
|
|
169
172
|
_finished(emit, e.id, report)
|
|
173
|
+
return report
|
|
174
|
+
|
|
175
|
+
# D36: a policy abort fails only its entity; a FloeError still stops the run
|
|
176
|
+
if workers > 1 and len(selected) > 1:
|
|
177
|
+
keys = [exclusive_keys(e, config_dir, root.storages, root.catalogs) for e in selected] # fmt: skip
|
|
178
|
+
done = run_parallel(selected, keys, workers, finish)
|
|
179
|
+
else:
|
|
180
|
+
done = [finish(e) for e in selected]
|
|
181
|
+
reports = [(id_path(e), r) for e, r in zip(selected, done, strict=True)]
|
|
170
182
|
summary = build_summary(
|
|
171
183
|
root.version, reports,
|
|
172
184
|
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(
|
|
@@ -411,7 +411,9 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
|
|
|
411
411
|
first, probe = [], xml_columns(path, entity.source.options)
|
|
412
412
|
elif entity.source.format == "json":
|
|
413
413
|
# D40: every record's top-level keys; D41: only a file without records has nothing to compare
|
|
414
|
-
|
|
414
|
+
# a file listed as small is parsed once: the read reuses the probe's records (limit 0 keeps none)
|
|
415
|
+
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
|
|
416
|
+
chunks = _chunks(entity, path, file_names, head)
|
|
415
417
|
first, probe = [], declared if json_keys is None else json_keys
|
|
416
418
|
else: # the first chunk's columns are the input columns the plan compares
|
|
417
419
|
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,18 +1,18 @@
|
|
|
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
18
|
floe/checks/unique.py,sha256=qoZVqS_uG_mWZlV7-LdxT6_dMVQRlklEvMutO_V0qaY,12396
|
|
@@ -20,34 +20,35 @@ 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=R0IBTaLe80k49SUuVMoAw0j7Ira2EDgjjk3XHSISIGg,16573
|
|
31
31
|
floe/iceberg/compact.py,sha256=5O372vAIsvgRc2PGihryAsrQ0otn0F30AQu-cetRCP0,7123
|
|
32
32
|
floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
|
|
33
33
|
floe/iceberg/sink.py,sha256=WyT6yrp5JQyfaqWES96NWh3V-EzgTD4Y-IjB84mZ9Sg,11842
|
|
34
34
|
floe/iceberg/unique.py,sha256=OmiPQthhB5jGTaXsCu9ZztNFEFUf6eT0MLU2Gnh-C2A,1095
|
|
35
35
|
floe/iceberg/write.py,sha256=SRX5tb17zUeWwpg6uXKkVQ_muNJ2GqlQd0n-kmjWPVc,11141
|
|
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=leKCs7YOoPADLFzKzU8m-mdfmStbGz_VH9kAg12kzyo,8902
|
|
49
|
+
floe/run/entity.py,sha256=e-IfES1ajZtIBrX9UnWDOpFHSoxjfYRtCBp63QavCFY,49653
|
|
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.8.dist-info/METADATA,sha256=nkx-wlv0W9muP3wviq1E6TBG-fS7wPa1Szy7qjGig_E,442
|
|
65
|
+
floe_python-0.8.8.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
66
|
+
floe_python-0.8.8.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
|
|
67
|
+
floe_python-0.8.8.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
|
|
68
|
+
floe_python-0.8.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|