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/iceberg/compact.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Experimental compaction (#952): rewrite every data file, fold the position deletes, one REPLACE commit.
|
|
2
|
+
|
|
3
|
+
PyIceberg 0.12 has no `RewriteFiles`, so this subclasses its private `_OverwriteFiles` and `ManifestWriterV2`
|
|
4
|
+
(spike #618 S5). `tests/unit/iceberg/test_compact.py` pins the PyIceberg version they were checked against.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingTypeStubs=false, reportPrivateUsage=false
|
|
8
|
+
# ^ pyarrow ships no type stubs; the private PyIceberg internals are the point of this module
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
import duckdb
|
|
13
|
+
import pyarrow as pa
|
|
14
|
+
from pyiceberg.io.pyarrow import _dataframe_to_data_files
|
|
15
|
+
from pyiceberg.manifest import (
|
|
16
|
+
DataFileContent,
|
|
17
|
+
ManifestContent,
|
|
18
|
+
ManifestEntry,
|
|
19
|
+
ManifestFile,
|
|
20
|
+
ManifestWriter,
|
|
21
|
+
ManifestWriterV2,
|
|
22
|
+
)
|
|
23
|
+
from pyiceberg.table import Table
|
|
24
|
+
from pyiceberg.table.snapshots import Operation, Summary
|
|
25
|
+
from pyiceberg.table.update import UpdatesAndRequirements
|
|
26
|
+
from pyiceberg.table.update.snapshot import CommitWindow, _OverwriteFiles
|
|
27
|
+
from pyiceberg.table.update.validate import (
|
|
28
|
+
_validate_data_files_exist,
|
|
29
|
+
_validate_no_new_deletes_for_data_files,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from floe.errors import FloeRunError
|
|
33
|
+
from floe.iceberg.catalog import run_error
|
|
34
|
+
from floe.iceberg.merge import _target
|
|
35
|
+
from floe.iceberg.write import CHUNK_ROWS
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _DeleteManifestWriter(ManifestWriterV2):
|
|
39
|
+
"""PyIceberg 0.12's `ManifestWriterV2` writes `content=data` manifests only."""
|
|
40
|
+
|
|
41
|
+
def content(self) -> ManifestContent:
|
|
42
|
+
return ManifestContent.DELETES
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def _meta(self) -> dict[str, str]:
|
|
46
|
+
return {**super()._meta, "content": "deletes"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _RewriteFiles(_OverwriteFiles):
|
|
50
|
+
"""Java `RewriteFiles`: `_deleted_data_files` holds the rewritten data files and their delete files."""
|
|
51
|
+
|
|
52
|
+
def _deleted_entries(self) -> list[ManifestEntry]:
|
|
53
|
+
return [] # marked DELETED in place by `_existing_manifests`
|
|
54
|
+
|
|
55
|
+
def _existing_manifests(self) -> list[ManifestFile]:
|
|
56
|
+
"""The head's manifests; one that holds a removed file is rewritten with that entry DELETED."""
|
|
57
|
+
out: list[ManifestFile] = []
|
|
58
|
+
head = self._transaction.table_metadata.snapshot_by_name(self._target_branch)
|
|
59
|
+
for manifest in head.manifests(self._io) if head else []:
|
|
60
|
+
entries = manifest.fetch_manifest_entry(self._io, discard_deleted=True)
|
|
61
|
+
if not any(e.data_file in self._deleted_data_files for e in entries):
|
|
62
|
+
out.append(manifest)
|
|
63
|
+
continue
|
|
64
|
+
spec = self.spec(manifest.partition_spec_id)
|
|
65
|
+
writer: ManifestWriter = (
|
|
66
|
+
_DeleteManifestWriter(spec, self.schema(), self.new_manifest_output(), self._snapshot_id, self._compression)
|
|
67
|
+
if manifest.content == ManifestContent.DELETES
|
|
68
|
+
else self.new_manifest_writer(spec)
|
|
69
|
+
) # fmt: skip
|
|
70
|
+
with writer:
|
|
71
|
+
for e in entries:
|
|
72
|
+
writer.delete(e) if e.data_file in self._deleted_data_files else writer.existing(e) # fmt: skip
|
|
73
|
+
out.append(writer.to_manifest_file())
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def _summary(self, snapshot_properties: Mapping[str, str] = {}) -> Summary:
|
|
77
|
+
# built as an OVERWRITE: PyIceberg 0.12's `update_snapshot_summaries` rejects REPLACE
|
|
78
|
+
summary = super()._summary(dict(snapshot_properties))
|
|
79
|
+
return Summary(Operation.REPLACE, **summary.additional_properties)
|
|
80
|
+
|
|
81
|
+
def _validate_concurrency(self) -> None:
|
|
82
|
+
"""Only a removed rewritten file, or a new delete against one, conflicts; concurrent appends don't."""
|
|
83
|
+
window = self._commit_window
|
|
84
|
+
if window is None or window.is_empty() or window.head is None:
|
|
85
|
+
return
|
|
86
|
+
table, data = self._transaction._table, {f for f in self._deleted_data_files if f.content == DataFileContent.DATA} # fmt: skip
|
|
87
|
+
_validate_data_files_exist(
|
|
88
|
+
table, window.head, self._deleted_data_files, window.base
|
|
89
|
+
)
|
|
90
|
+
_validate_no_new_deletes_for_data_files(
|
|
91
|
+
table, window.head, None, data, window.base
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _commit(self) -> UpdatesAndRequirements:
|
|
95
|
+
# first attempt: validate the commits since the snapshot the rewrite read
|
|
96
|
+
if self._commit_window is None:
|
|
97
|
+
self._commit_window = CommitWindow.resolve(self._transaction.table_metadata, self._starting_snapshot_id, self._target_branch) # fmt: skip
|
|
98
|
+
self._validate_concurrency()
|
|
99
|
+
return super()._commit()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def compact(table: Table, con: duckdb.DuckDBPyConnection) -> dict[str, int]:
|
|
103
|
+
"""Rewrite the whole table at its current snapshot through DuckDB (deletes applied), then commit once.
|
|
104
|
+
|
|
105
|
+
Concurrent appends are kept; a concurrent MERGE on the same files makes the compaction fail.
|
|
106
|
+
"""
|
|
107
|
+
base = table.current_snapshot()
|
|
108
|
+
if base is None:
|
|
109
|
+
return {"rewritten_data_files": 0, "removed_delete_files": 0, "added_data_files": 0} # fmt: skip
|
|
110
|
+
if not table.spec().is_unpartitioned() or table.format_version != 2:
|
|
111
|
+
# ponytail: PyIceberg 0.12 streams unpartitioned v2 writes only; partitioned needs an ordered per-partition flush
|
|
112
|
+
raise FloeRunError(f"iceberg compaction supports unpartitioned format v2 tables only ({'.'.join(table.name())})") # fmt: skip
|
|
113
|
+
with run_error("iceberg compaction plan failed"):
|
|
114
|
+
tasks = list(table.scan(snapshot_id=base.snapshot_id).plan_files())
|
|
115
|
+
data = {t.file for t in tasks}
|
|
116
|
+
deletes = {d for t in tasks for d in t.delete_files}
|
|
117
|
+
if any(d.content == DataFileContent.EQUALITY_DELETES for d in deletes):
|
|
118
|
+
raise FloeRunError("iceberg compaction does not support equality deletes")
|
|
119
|
+
source = f"{_target(table)} AT (VERSION => {base.snapshot_id})"
|
|
120
|
+
schema = table.schema().as_arrow()
|
|
121
|
+
with run_error("iceberg compaction rewrite failed"):
|
|
122
|
+
cursor = con.execute(f"SELECT * FROM {source}")
|
|
123
|
+
batches = (b.cast(schema) for b in cursor.to_arrow_reader(CHUNK_ROWS))
|
|
124
|
+
new = list(_dataframe_to_data_files(table.metadata, pa.RecordBatchReader.from_batches(schema, batches), table.io)) # fmt: skip
|
|
125
|
+
live = con.execute(f"SELECT count(*) FROM {source}").fetchone()
|
|
126
|
+
if live is None or live[0] != sum(f.record_count for f in new):
|
|
127
|
+
raise FloeRunError(f"iceberg compaction wrote {sum(f.record_count for f in new)} rows, expected {live and live[0]}; nothing was committed") # fmt: skip
|
|
128
|
+
with run_error("iceberg compaction commit failed"):
|
|
129
|
+
table.refresh() # the head may have moved: its manifests are the ones carried over
|
|
130
|
+
tx = table.transaction()
|
|
131
|
+
rewrite = _RewriteFiles(Operation.OVERWRITE, tx, table.io, snapshot_properties={"floe.maintenance": "compact"}) # fmt: skip
|
|
132
|
+
rewrite._starting_snapshot_id = base.snapshot_id
|
|
133
|
+
for f in data | deletes:
|
|
134
|
+
rewrite.delete_data_file(f)
|
|
135
|
+
for f in new:
|
|
136
|
+
rewrite.append_data_file(f)
|
|
137
|
+
rewrite.commit()
|
|
138
|
+
tx.commit_transaction()
|
|
139
|
+
return {"rewritten_data_files": len(data), "removed_delete_files": len(deletes), "added_data_files": len(new)} # fmt: skip
|
floe/iceberg/merge.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""SCD1 and SCD2 merges into Iceberg: the batch is staged as Parquet, then DuckDB `MERGE INTO` (#598).
|
|
2
|
+
|
|
3
|
+
SCD1 follows `io/write/strategy/merge/scd1.rs`: match on the merge key, update every non-key,
|
|
4
|
+
non-ignored column, insert unmatched rows with every column. Rust updates every matched row; here
|
|
5
|
+
the update is guarded by a null-safe change predicate, so rerunning a batch writes nothing. The
|
|
6
|
+
resulting rows are the same. SCD2 follows `merge/scd2.rs` (see `scd2_sql`).
|
|
7
|
+
|
|
8
|
+
A bucket-partitioned target gets one MERGE per bucket in one transaction, so one catalog commit:
|
|
9
|
+
a single MERGE runs out of memory there (#609).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# ponytail: DuckDB only as the Iceberg MERGE writer (merge-on-read, bounded memory); switch to Polars/PyIceberg and drop duckdb per #827 once upstream supports it.
|
|
13
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportMissingTypeStubs=false
|
|
14
|
+
# ^ pyarrow ships no type stubs
|
|
15
|
+
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import tempfile
|
|
18
|
+
from collections.abc import Callable, Collection, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import duckdb
|
|
23
|
+
import pyarrow as pa
|
|
24
|
+
import pyarrow.compute as pc
|
|
25
|
+
import pyarrow.parquet as pq
|
|
26
|
+
from pyiceberg.partitioning import PartitionSpec
|
|
27
|
+
from pyiceberg.schema import Schema
|
|
28
|
+
from pyiceberg.table import Table
|
|
29
|
+
from pyiceberg.transforms import BucketTransform
|
|
30
|
+
|
|
31
|
+
from floe.config.validate import SCD2_DEFAULTS
|
|
32
|
+
from floe.errors import FloeRunError
|
|
33
|
+
from floe.iceberg.catalog import DUCKDB_CATALOG, run_error
|
|
34
|
+
from floe.iceberg.write import IcebergWriteResult, added_data_files, write_result
|
|
35
|
+
from floe.state import (
|
|
36
|
+
_backoff, # pyright: ignore[reportPrivateUsage] -- one jittered backoff for every CAS retry
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# (is_current, valid_from, valid_to)
|
|
40
|
+
SCD2_COLUMNS = tuple(SCD2_DEFAULTS.values())
|
|
41
|
+
MERGE_ATTEMPTS = 5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _q(name: str) -> str:
|
|
45
|
+
return '"' + name.replace('"', '""') + '"'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class MergeResult:
|
|
50
|
+
write: IcebergWriteResult
|
|
51
|
+
inserted_count: int
|
|
52
|
+
# SCD1: matched rows that changed; Rust counts every matched row (D2). SCD2: closed rows.
|
|
53
|
+
updated_count: int
|
|
54
|
+
closed_count: int | None = None
|
|
55
|
+
unchanged_count: int | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def scd1_sql(
|
|
59
|
+
target: str, columns: Sequence[str], key: Sequence[str], ignore: Collection[str]
|
|
60
|
+
) -> tuple[str, str]:
|
|
61
|
+
"""(count, merge) statements: the source is `read_parquet($1)`, aliased `s`; the target is `t`.
|
|
62
|
+
|
|
63
|
+
The count returns (inserted, updated) as the merge will apply them. `MERGE ... RETURNING`
|
|
64
|
+
can't be aggregated in SQL, and returning every row would not stay bounded.
|
|
65
|
+
"""
|
|
66
|
+
update = [c for c in columns if c not in key and c not in ignore]
|
|
67
|
+
on = " AND ".join(f"t.{_q(k)} = s.{_q(k)}" for k in key)
|
|
68
|
+
changed = " OR ".join(f"t.{_q(c)} IS DISTINCT FROM s.{_q(c)}" for c in update)
|
|
69
|
+
matched = ""
|
|
70
|
+
if update:
|
|
71
|
+
assign = ", ".join(f"{_q(c)} = s.{_q(c)}" for c in update)
|
|
72
|
+
matched = f"WHEN MATCHED AND ({changed}) THEN UPDATE SET {assign} "
|
|
73
|
+
# a matched target key is never NULL, since NULL = x never holds
|
|
74
|
+
unmatched = f"t.{_q(key[0])} IS NULL"
|
|
75
|
+
count = (
|
|
76
|
+
f"SELECT count(*) FILTER ({unmatched}), "
|
|
77
|
+
f"count(*) FILTER (NOT {unmatched} AND ({changed or 'false'})) "
|
|
78
|
+
f"FROM read_parquet($1) s LEFT JOIN {target} t ON {on}"
|
|
79
|
+
)
|
|
80
|
+
merge = (
|
|
81
|
+
f"MERGE INTO {target} t USING read_parquet($1) s ON {on} {matched}"
|
|
82
|
+
f"WHEN NOT MATCHED THEN INSERT ({', '.join(map(_q, columns))}) "
|
|
83
|
+
f"VALUES ({', '.join(f's.{_q(c)}' for c in columns)})"
|
|
84
|
+
)
|
|
85
|
+
return count, merge
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def scd2_sql(
|
|
89
|
+
target: str,
|
|
90
|
+
columns: Sequence[str],
|
|
91
|
+
key: Sequence[str],
|
|
92
|
+
compare: Sequence[str],
|
|
93
|
+
system: Sequence[str],
|
|
94
|
+
now: dt.datetime,
|
|
95
|
+
) -> tuple[str, str, str]:
|
|
96
|
+
"""(count, changed, merge), as `scd1_sql`; the count returns (inserted, closed, unchanged).
|
|
97
|
+
|
|
98
|
+
Rust closes changed current rows, then inserts new versions, in two Delta merges. Here it is
|
|
99
|
+
one MERGE: each changed row is in the source twice, once with its key (it matches the current
|
|
100
|
+
version and closes it) and once with a NULL key (it never matches, so it is inserted).
|
|
101
|
+
`changed` copies the changed rows of `$1` to `$2` first, so the MERGE's source never reads
|
|
102
|
+
the target the MERGE is writing.
|
|
103
|
+
"""
|
|
104
|
+
is_current, valid_from, valid_to = map(_q, system)
|
|
105
|
+
on = " AND ".join(f"t.{_q(k)} = s.{_q(k)}" for k in key) + f" AND t.{is_current}"
|
|
106
|
+
changed = " OR ".join(f"t.{_q(c)} IS DISTINCT FROM s.{_q(c)}" for c in compare)
|
|
107
|
+
changed = f"({changed or 'false'})"
|
|
108
|
+
# the join keeps only current target rows, whose flag is never NULL
|
|
109
|
+
unmatched = f"t.{is_current} IS NULL"
|
|
110
|
+
count = (
|
|
111
|
+
f"SELECT count(*) FILTER ({unmatched} OR {changed}), "
|
|
112
|
+
f"count(*) FILTER (NOT {unmatched} AND {changed}), "
|
|
113
|
+
f"count(*) FILTER (NOT {unmatched} AND NOT {changed}) "
|
|
114
|
+
f"FROM read_parquet($1) s LEFT JOIN {target} t ON {on}"
|
|
115
|
+
)
|
|
116
|
+
keys = [f"__floe_key_{i}" for i in range(len(key))]
|
|
117
|
+
values = ", ".join(f"s.{_q(c)}" for c in columns)
|
|
118
|
+
copy = (
|
|
119
|
+
f"COPY (SELECT {values} FROM read_parquet($1) s JOIN {target} t ON {on} "
|
|
120
|
+
f"WHERE {changed}) TO $2 (FORMAT parquet)"
|
|
121
|
+
)
|
|
122
|
+
source = (
|
|
123
|
+
f"SELECT {', '.join(f's.{_q(k)} AS {m}' for k, m in zip(key, keys))}, {values} "
|
|
124
|
+
f"FROM read_parquet($1) s UNION ALL "
|
|
125
|
+
f"SELECT {', '.join(f'NULL AS {m}' for m in keys)}, {values} FROM read_parquet($2) s"
|
|
126
|
+
)
|
|
127
|
+
merge_on = " AND ".join(f"t.{_q(k)} = s.{m}" for k, m in zip(key, keys))
|
|
128
|
+
ts = f"TIMESTAMP '{now.isoformat(sep=' ')}'"
|
|
129
|
+
insert = ", ".join([*map(_q, columns), is_current, valid_from, valid_to])
|
|
130
|
+
merge = (
|
|
131
|
+
f"MERGE INTO {target} t USING ({source}) s ON {merge_on} AND t.{is_current} "
|
|
132
|
+
f"WHEN MATCHED AND {changed} THEN UPDATE SET {is_current} = false, {valid_to} = {ts} "
|
|
133
|
+
f"WHEN NOT MATCHED THEN INSERT ({insert}) VALUES ({values}, true, {ts}, NULL)"
|
|
134
|
+
)
|
|
135
|
+
return count, copy, merge
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def stage_batch(
|
|
139
|
+
directory: Path, reader: pa.RecordBatchReader, spec: PartitionSpec, schema: Schema
|
|
140
|
+
) -> list[str]:
|
|
141
|
+
"""The batch as one Parquet file, or one per bucket when `spec` has a bucket field."""
|
|
142
|
+
bucket = next(
|
|
143
|
+
(f for f in spec.fields if isinstance(f.transform, BucketTransform)), None
|
|
144
|
+
)
|
|
145
|
+
writers: dict[object, pq.ParquetWriter] = {}
|
|
146
|
+
|
|
147
|
+
def write(value: object, batch: pa.RecordBatch) -> None:
|
|
148
|
+
if value not in writers:
|
|
149
|
+
path = directory / f"batch-{len(writers)}.parquet"
|
|
150
|
+
writers[value] = pq.ParquetWriter(path, reader.schema)
|
|
151
|
+
writers[value].write_batch(batch)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
for batch in reader:
|
|
155
|
+
if bucket is None:
|
|
156
|
+
write(None, batch)
|
|
157
|
+
continue
|
|
158
|
+
column = schema.find_column_name(bucket.source_id)
|
|
159
|
+
source_type = schema.find_type(bucket.source_id)
|
|
160
|
+
buckets = bucket.transform.pyarrow_transform(source_type)(
|
|
161
|
+
batch.column(column)
|
|
162
|
+
)
|
|
163
|
+
for value in buckets.unique().to_pylist():
|
|
164
|
+
mask = (
|
|
165
|
+
buckets.is_null() if value is None else pc.equal(buckets, value) # pyright: ignore[reportAttributeAccessIssue] -- pyarrow.compute builds its functions at import
|
|
166
|
+
)
|
|
167
|
+
write(value, batch.filter(mask))
|
|
168
|
+
if not writers: # an empty batch still needs a file to read
|
|
169
|
+
write(None, pa.RecordBatch.from_pylist([], reader.schema))
|
|
170
|
+
finally:
|
|
171
|
+
for writer in writers.values():
|
|
172
|
+
writer.close()
|
|
173
|
+
return [str(directory / f"batch-{i}.parquet") for i in range(len(writers))]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_merge(
|
|
177
|
+
con: duckdb.DuckDBPyConnection,
|
|
178
|
+
count: str,
|
|
179
|
+
merge: str,
|
|
180
|
+
files: list[str],
|
|
181
|
+
changed: str | None = None,
|
|
182
|
+
) -> tuple[int, ...]:
|
|
183
|
+
"""The counts, then one MERGE per staged file, all in one transaction (one catalog commit).
|
|
184
|
+
|
|
185
|
+
With `changed` (SCD2), each file's changed rows are copied next to it before any MERGE.
|
|
186
|
+
"""
|
|
187
|
+
counts = con.execute(count, [files]).fetchone()
|
|
188
|
+
assert counts is not None # an aggregate always returns one row
|
|
189
|
+
# nothing to insert, update or close: no MERGE, so no snapshot
|
|
190
|
+
if counts[0] or counts[1]:
|
|
191
|
+
params = [
|
|
192
|
+
[f] if changed is None else [f, f"{f}.changed.parquet"] for f in files
|
|
193
|
+
]
|
|
194
|
+
if changed is not None:
|
|
195
|
+
for p in params:
|
|
196
|
+
con.execute(changed, p)
|
|
197
|
+
con.begin()
|
|
198
|
+
try:
|
|
199
|
+
for p in params:
|
|
200
|
+
con.execute(merge, p)
|
|
201
|
+
except BaseException:
|
|
202
|
+
con.rollback()
|
|
203
|
+
raise
|
|
204
|
+
con.commit() # a failed commit has already ended the transaction: no rollback
|
|
205
|
+
return counts
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_conflict(e: FloeRunError) -> bool:
|
|
209
|
+
"""The catalog's 409 on commit: DuckDB raises it from `commit()` and does not retry it."""
|
|
210
|
+
return isinstance(e.__cause__, duckdb.TransactionException) and "Conflict_409" in str(e) # fmt: skip
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _merge(
|
|
214
|
+
con: duckdb.DuckDBPyConnection,
|
|
215
|
+
table: Table,
|
|
216
|
+
reader: pa.RecordBatchReader,
|
|
217
|
+
statements: Callable[[], tuple[str, str, str | None]],
|
|
218
|
+
) -> tuple[IcebergWriteResult, tuple[int, ...]]:
|
|
219
|
+
"""`statements` gives (count, merge, changed); it runs per attempt, so SCD2 stamps each retry's own time.
|
|
220
|
+
|
|
221
|
+
On a commit conflict the whole MERGE runs again on the fresh table (#922): the counts and
|
|
222
|
+
the changed rows are recomputed, so a key both writers inserted becomes an update.
|
|
223
|
+
"""
|
|
224
|
+
with tempfile.TemporaryDirectory(prefix="floe-merge-") as staging:
|
|
225
|
+
with run_error("iceberg merge staging failed"):
|
|
226
|
+
files = stage_batch(Path(staging), reader, table.spec(), table.schema())
|
|
227
|
+
for attempt in range(MERGE_ATTEMPTS):
|
|
228
|
+
if attempt:
|
|
229
|
+
_backoff(attempt - 1)
|
|
230
|
+
with run_error("rest catalog load_table failed"):
|
|
231
|
+
table.refresh()
|
|
232
|
+
before = table.current_snapshot()
|
|
233
|
+
try:
|
|
234
|
+
with run_error("duckdb merge failed"):
|
|
235
|
+
count, merge, changed = statements()
|
|
236
|
+
counts = run_merge(con, count, merge, files, changed)
|
|
237
|
+
except FloeRunError as e:
|
|
238
|
+
if not _is_conflict(e):
|
|
239
|
+
raise
|
|
240
|
+
continue
|
|
241
|
+
break
|
|
242
|
+
else:
|
|
243
|
+
raise FloeRunError(f"duckdb merge failed: conflicted {MERGE_ATTEMPTS} times in a row") # fmt: skip
|
|
244
|
+
with run_error("rest catalog load_table failed"):
|
|
245
|
+
table.refresh()
|
|
246
|
+
added = added_data_files(table, None if before is None else before.snapshot_id)
|
|
247
|
+
return write_result(table, added), counts
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _target(table: Table) -> str:
|
|
251
|
+
return ".".join(map(_q, (DUCKDB_CATALOG, *table.name())))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def merge_scd1(
|
|
255
|
+
con: duckdb.DuckDBPyConnection,
|
|
256
|
+
table: Table,
|
|
257
|
+
reader: pa.RecordBatchReader,
|
|
258
|
+
merge_key: Sequence[str],
|
|
259
|
+
ignore_columns: Collection[str] = (),
|
|
260
|
+
) -> MergeResult:
|
|
261
|
+
"""`con` comes from `open_duckdb` on the table's catalog; the table already has the batch's columns."""
|
|
262
|
+
count, merge = scd1_sql(
|
|
263
|
+
_target(table), reader.schema.names, merge_key, ignore_columns
|
|
264
|
+
)
|
|
265
|
+
write, counts = _merge(con, table, reader, lambda: (count, merge, None))
|
|
266
|
+
return MergeResult(write, counts[0], counts[1])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def merge_scd2(
|
|
270
|
+
con: duckdb.DuckDBPyConnection,
|
|
271
|
+
table: Table,
|
|
272
|
+
reader: pa.RecordBatchReader,
|
|
273
|
+
merge_key: Sequence[str],
|
|
274
|
+
compare_columns: Sequence[str] | None = None,
|
|
275
|
+
ignore_columns: Collection[str] = (),
|
|
276
|
+
system_columns: Sequence[str] = SCD2_COLUMNS,
|
|
277
|
+
) -> MergeResult:
|
|
278
|
+
"""As `merge_scd1`; the table also has the system columns (is_current, valid_from, valid_to).
|
|
279
|
+
|
|
280
|
+
Compare columns default to every non-key, non-ignored batch column, as in Rust.
|
|
281
|
+
"""
|
|
282
|
+
columns = reader.schema.names
|
|
283
|
+
if compare_columns is None:
|
|
284
|
+
compare_columns = [
|
|
285
|
+
c for c in columns if c not in merge_key and c not in ignore_columns
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
def statements() -> tuple[str, str, str | None]:
|
|
289
|
+
# Rust stamps both versions with the UTC time of the merge, as a naive timestamp
|
|
290
|
+
now = dt.datetime.now(dt.UTC).replace(tzinfo=None)
|
|
291
|
+
count, changed, merge = scd2_sql(
|
|
292
|
+
_target(table), columns, merge_key, compare_columns, system_columns, now
|
|
293
|
+
)
|
|
294
|
+
return count, merge, changed
|
|
295
|
+
|
|
296
|
+
write, (inserted, closed, unchanged) = _merge(con, table, reader, statements)
|
|
297
|
+
return MergeResult(write, inserted, closed, closed, unchanged)
|
floe/iceberg/sink.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The run's Iceberg accepted sink: catalog target, table and one commit (Rust `io/write/iceberg.rs` `write`)."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportMissingTypeStubs=false
|
|
4
|
+
# ^ pyarrow ships no type stubs
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterator, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
import pyarrow as pa
|
|
16
|
+
from pyiceberg.catalog import Catalog
|
|
17
|
+
from pyiceberg.schema import Schema, assign_fresh_schema_ids
|
|
18
|
+
from pyiceberg.table import Table
|
|
19
|
+
|
|
20
|
+
from floe.checks.unique import resolve_unique_keys
|
|
21
|
+
from floe.config.storage import (
|
|
22
|
+
CatalogDefinition,
|
|
23
|
+
CatalogsConfig,
|
|
24
|
+
StorageDefinition,
|
|
25
|
+
StoragesConfig,
|
|
26
|
+
)
|
|
27
|
+
from floe.config.types import EntityConfig, WriteMode
|
|
28
|
+
from floe.config.validate import SCD2_DEFAULTS
|
|
29
|
+
from floe.errors import FloeConfigError, FloeRunError
|
|
30
|
+
from floe.iceberg.catalog import (
|
|
31
|
+
PartitionFields,
|
|
32
|
+
ensure_table,
|
|
33
|
+
open_catalog,
|
|
34
|
+
open_duckdb,
|
|
35
|
+
run_error,
|
|
36
|
+
)
|
|
37
|
+
from floe.iceberg.merge import merge_scd1, merge_scd2
|
|
38
|
+
from floe.iceberg.write import (
|
|
39
|
+
SMALL_FILE_THRESHOLD_BYTES,
|
|
40
|
+
IcebergWriteResult,
|
|
41
|
+
write_table,
|
|
42
|
+
)
|
|
43
|
+
from floe.storage.inputs import InputFile
|
|
44
|
+
from floe.storage.resolve import resolve_path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class IcebergTarget:
|
|
49
|
+
catalog: CatalogDefinition
|
|
50
|
+
storage: StorageDefinition | None # the table location's storage
|
|
51
|
+
namespace: str
|
|
52
|
+
table: str
|
|
53
|
+
location: str
|
|
54
|
+
accepted_uri: str # `sink.accepted.path` resolved, as Rust reports it
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def catalog_ident(value: str) -> str:
|
|
58
|
+
"""Rust `normalize_catalog_ident`."""
|
|
59
|
+
out = "".join(c.lower() if c.isascii() and (c.isalnum() or c in "_-") else "_" for c in value) # fmt: skip
|
|
60
|
+
return out.strip("_") or "default"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_target(storages: StoragesConfig | None, catalogs: CatalogsConfig | None, config_dir: Path, entity: EntityConfig) -> IcebergTarget: # fmt: skip
|
|
64
|
+
"""Rust `CatalogResolver::resolve_iceberg_target`; the catalog reference is validated."""
|
|
65
|
+
sink = entity.sink.accepted
|
|
66
|
+
if sink.iceberg is None:
|
|
67
|
+
# ponytail: Rust 0.7 also wrote catalog-less filesystem tables; PyIceberg has no such catalog without sqlalchemy
|
|
68
|
+
raise FloeRunError(f"entity.name={entity.name} sink.accepted.format=iceberg requires sink.accepted.iceberg with a catalog in 0.8") # fmt: skip
|
|
69
|
+
name = sink.iceberg.catalog or (catalogs and catalogs.default)
|
|
70
|
+
catalog = next((c for c in (catalogs.definitions if catalogs else []) if c.name == name), None) # fmt: skip
|
|
71
|
+
if catalog is None: # validated; kept for callers that skip validation
|
|
72
|
+
raise FloeConfigError(f"entity.name={entity.name} sink.accepted.iceberg.catalog references unknown catalog {name}") # fmt: skip
|
|
73
|
+
namespace = catalog_ident(sink.iceberg.namespace or entity.domain or "default")
|
|
74
|
+
table = catalog_ident(sink.iceberg.table or entity.name)
|
|
75
|
+
field = f"entity.name={entity.name} sink.accepted.storage"
|
|
76
|
+
accepted_uri, storage = resolve_path(storages, config_dir, sink.storage, sink.path, field) # fmt: skip
|
|
77
|
+
where = catalog.warehouse_storage or sink.storage
|
|
78
|
+
if sink.iceberg.location is not None:
|
|
79
|
+
location, storage = resolve_path(storages, config_dir, where, sink.iceberg.location, field) # fmt: skip
|
|
80
|
+
elif catalog.warehouse_storage is not None or catalog.warehouse_prefix is not None:
|
|
81
|
+
parts = ((catalog.warehouse_prefix or "").strip("/"), namespace, table)
|
|
82
|
+
location, storage = resolve_path(storages, config_dir, where, "/".join(p for p in parts if p), field) # fmt: skip
|
|
83
|
+
else:
|
|
84
|
+
location = accepted_uri
|
|
85
|
+
return IcebergTarget(catalog, storage, namespace, table, location, accepted_uri) # fmt: skip
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def existing_table(target: IcebergTarget) -> Table | None:
|
|
89
|
+
catalog = open_catalog(target.catalog, target.storage)
|
|
90
|
+
identifier = (target.namespace, target.table)
|
|
91
|
+
with run_error("rest catalog table_exists check failed"):
|
|
92
|
+
if not catalog.table_exists(identifier):
|
|
93
|
+
return None
|
|
94
|
+
with run_error("rest catalog load_table failed"):
|
|
95
|
+
return catalog.load_table(identifier)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def input_hash(entity: EntityConfig, files: Sequence[InputFile]) -> str:
|
|
99
|
+
"""`floe.input_hash`: the entity and its claimed files with size and mtime (ADR "Retry guard"; Rust has none)."""
|
|
100
|
+
claimed = sorted([f.source_uri, f.source_size, f.source_mtime] for f in files)
|
|
101
|
+
return hashlib.sha256(json.dumps([entity.id, claimed]).encode()).hexdigest()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_WIDER: dict[pl.DataType, pl.DataType] = {pl.UInt8(): pl.Int32(), pl.UInt16(): pl.Int32(), pl.UInt32(): pl.Int64()} # fmt: skip
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _reader(entity: EntityConfig, frames: list[pl.DataFrame], names: list[str]) -> pa.RecordBatchReader: # fmt: skip
|
|
108
|
+
"""Output columns in schema order; `nullable: false` columns are required, as in Rust."""
|
|
109
|
+
columns = entity.schema_.columns
|
|
110
|
+
for name, c in zip(names, columns, strict=True):
|
|
111
|
+
if c.nullable is False and any(f[name].null_count() for f in frames):
|
|
112
|
+
raise FloeRunError(f"iceberg write rejected nulls for non-nullable column {name}") # fmt: skip
|
|
113
|
+
|
|
114
|
+
def arrow(df: pl.DataFrame) -> pa.Table: # Iceberg has no unsigned types
|
|
115
|
+
return df.select(pl.col(n).cast(_WIDER.get(df.schema[n], df.schema[n])) for n in names).to_arrow() # fmt: skip
|
|
116
|
+
|
|
117
|
+
schema = arrow(frames[0].head(0)).schema
|
|
118
|
+
for i, c in enumerate(columns):
|
|
119
|
+
schema = schema.set(i, schema.field(i).with_nullable(c.nullable is not False))
|
|
120
|
+
|
|
121
|
+
def batches() -> Iterator[pa.RecordBatch]:
|
|
122
|
+
for df in frames:
|
|
123
|
+
yield from arrow(df).cast(schema).to_batches()
|
|
124
|
+
|
|
125
|
+
return pa.RecordBatchReader.from_batches(schema, batches())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def partition_fields(entity: EntityConfig) -> PartitionFields | None:
|
|
129
|
+
"""`partition_spec` as `ensure_table` takes it; a bucket is PyIceberg's `bucket[N]` (D12)."""
|
|
130
|
+
spec = entity.sink.accepted.partition_spec
|
|
131
|
+
if spec is None:
|
|
132
|
+
return None
|
|
133
|
+
return [(f.column, f.transform if f.num_buckets is None else f"bucket[{f.num_buckets}]") for f in spec] # fmt: skip
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def write_iceberg(
|
|
137
|
+
entity: EntityConfig,
|
|
138
|
+
target: IcebergTarget,
|
|
139
|
+
frames: list[pl.DataFrame],
|
|
140
|
+
names: list[str],
|
|
141
|
+
mode: WriteMode,
|
|
142
|
+
run_id: str,
|
|
143
|
+
digest: str,
|
|
144
|
+
) -> tuple[IcebergWriteResult, dict[str, Any], dict[str, Any] | None]:
|
|
145
|
+
"""`frames` hold the output columns `names`; returns the result, its `accepted_output` table and merge fields."""
|
|
146
|
+
reader = _reader(entity, frames, names)
|
|
147
|
+
options = entity.sink.accepted.merge
|
|
148
|
+
scd2 = options.scd2 if options else None
|
|
149
|
+
# (is_current, valid_from, valid_to), as Rust `resolve_scd2_system_columns`
|
|
150
|
+
system = [(d if (v := getattr(scd2, f, None)) is None else v).strip() for f, d in SCD2_DEFAULTS.items()] # fmt: skip
|
|
151
|
+
table_schema = reader.schema
|
|
152
|
+
if mode == "merge_scd2": # the table also holds the system columns (Rust `scd2.rs`)
|
|
153
|
+
for name, kind, nullable in zip(system, (pa.bool_(), pa.timestamp("us"), pa.timestamp("us")), (False, False, True)): # fmt: skip
|
|
154
|
+
table_schema = table_schema.append(pa.field(name, kind, nullable=nullable))
|
|
155
|
+
arrow_schema = Catalog._convert_schema_if_needed(table_schema) # pyright: ignore[reportPrivateUsage] -- PyIceberg's own pyarrow -> Iceberg conversion
|
|
156
|
+
schema: Schema = assign_fresh_schema_ids(arrow_schema)
|
|
157
|
+
evolution = entity.schema_.schema_evolution
|
|
158
|
+
catalog = open_catalog(target.catalog, target.storage)
|
|
159
|
+
output = {c.name: n for c, n in zip(entity.schema_.columns, names, strict=True)}
|
|
160
|
+
primary_key = entity.schema_.primary_key or [] # validated non-empty for merges
|
|
161
|
+
key = [output.get(k.strip(), k.strip()) for k in primary_key]
|
|
162
|
+
identifier = (target.namespace, target.table)
|
|
163
|
+
with run_error("rest catalog table_exists check failed"):
|
|
164
|
+
exists = mode in ("merge_scd1", "merge_scd2") and catalog.table_exists(identifier) # fmt: skip
|
|
165
|
+
if exists:
|
|
166
|
+
with run_error("rest catalog load_table failed"):
|
|
167
|
+
existing = set(catalog.load_table(identifier).schema().column_names)
|
|
168
|
+
if added := [k for k in key if k not in existing]: # Rust `merge/shared.rs`
|
|
169
|
+
raise FloeRunError(f"entity.name={entity.name} iceberg schema evolution failed: merge key columns cannot be added: {', '.join(added)}") # fmt: skip
|
|
170
|
+
table = ensure_table(
|
|
171
|
+
catalog, target.namespace, target.table, schema, entity.name, target.location.removeprefix("local://"),
|
|
172
|
+
partition_fields(entity), evolution.mode if evolution else "strict",
|
|
173
|
+
) # fmt: skip
|
|
174
|
+
merge: dict[str, Any] | None = None
|
|
175
|
+
if mode in ("merge_scd1", "merge_scd2"):
|
|
176
|
+
ignore = {output.get(c.strip(), c.strip()) for c in (options.ignore_columns or [])} if options else set() # fmt: skip
|
|
177
|
+
started = time.monotonic()
|
|
178
|
+
with open_duckdb(target.catalog, target.storage) as con:
|
|
179
|
+
if mode == "merge_scd1":
|
|
180
|
+
merged = merge_scd1(con, table, reader, key, ignore)
|
|
181
|
+
else:
|
|
182
|
+
compare = options and options.compare_columns
|
|
183
|
+
compare = list(dict.fromkeys(output.get(c.strip(), c.strip()) for c in compare if c.strip())) if compare else None # fmt: skip
|
|
184
|
+
merged = merge_scd2(con, table, reader, key, compare, ignore, system)
|
|
185
|
+
result = merged.write
|
|
186
|
+
merge = {
|
|
187
|
+
"merge_key": primary_key, # Rust's Delta merge reports the configured key
|
|
188
|
+
"inserted_count": merged.inserted_count,
|
|
189
|
+
"updated_count": merged.updated_count,
|
|
190
|
+
}
|
|
191
|
+
if mode == "merge_scd2": # Rust's report omits these for SCD1
|
|
192
|
+
merge |= {"closed_count": merged.closed_count, "unchanged_count": merged.unchanged_count} # fmt: skip
|
|
193
|
+
merge["merge_elapsed_ms"] = int((time.monotonic() - started) * 1000)
|
|
194
|
+
else:
|
|
195
|
+
options = entity.sink.accepted.options
|
|
196
|
+
size = options.max_size_per_file if options else None
|
|
197
|
+
# Rust `default_small_file_threshold_bytes`
|
|
198
|
+
threshold = SMALL_FILE_THRESHOLD_BYTES if size is None else max(1, size // 2)
|
|
199
|
+
append = "overwrite" if mode == "overwrite" else "append"
|
|
200
|
+
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
|
|
201
|
+
result = write_table(table, reader, append, run_id, digest, threshold, unique_keys=unique) # fmt: skip
|
|
202
|
+
ids = {"table_version": result.metadata_version, "snapshot_id": result.snapshot_id}
|
|
203
|
+
fields = {k: v for k, v in ids.items() if v is not None} | {
|
|
204
|
+
"iceberg_catalog_name": target.catalog.name,
|
|
205
|
+
"iceberg_namespace": target.namespace,
|
|
206
|
+
"iceberg_table": target.table,
|
|
207
|
+
}
|
|
208
|
+
return result, fields, merge
|