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.
Files changed (66) hide show
  1. floe/__init__.py +327 -0
  2. floe/__main__.py +5 -0
  3. floe/_cli.py +302 -0
  4. floe/_cli_output.py +187 -0
  5. floe/add_entity.py +516 -0
  6. floe/checks/cast.py +160 -0
  7. floe/checks/mismatch.py +137 -0
  8. floe/checks/normalize.py +118 -0
  9. floe/checks/rows.py +116 -0
  10. floe/checks/unique.py +247 -0
  11. floe/config/__init__.py +0 -0
  12. floe/config/entity.py +479 -0
  13. floe/config/include.py +122 -0
  14. floe/config/parse.py +140 -0
  15. floe/config/profile.py +381 -0
  16. floe/config/project.py +404 -0
  17. floe/config/storage.py +179 -0
  18. floe/config/types.py +195 -0
  19. floe/config/validate.py +803 -0
  20. floe/config/vars.py +127 -0
  21. floe/config/yaml.py +263 -0
  22. floe/errors.py +13 -0
  23. floe/glob.py +300 -0
  24. floe/iceberg/catalog.py +345 -0
  25. floe/iceberg/compact.py +139 -0
  26. floe/iceberg/merge.py +297 -0
  27. floe/iceberg/sink.py +208 -0
  28. floe/iceberg/unique.py +61 -0
  29. floe/iceberg/write.py +270 -0
  30. floe/lineage/events.py +163 -0
  31. floe/lineage/observer.py +261 -0
  32. floe/maintain.py +134 -0
  33. floe/manifest.py +503 -0
  34. floe/manifest_run.py +299 -0
  35. floe/pii.py +134 -0
  36. floe/py.typed +0 -0
  37. floe/read/__init__.py +0 -0
  38. floe/read/avro.py +40 -0
  39. floe/read/csv.py +138 -0
  40. floe/read/json.py +236 -0
  41. floe/read/orc.py +42 -0
  42. floe/read/parquet.py +43 -0
  43. floe/read/xlsx.py +75 -0
  44. floe/read/xml.py +227 -0
  45. floe/report/entity.py +245 -0
  46. floe/report/summary.py +114 -0
  47. floe/run/__init__.py +175 -0
  48. floe/run/entity.py +963 -0
  49. floe/run/events.py +42 -0
  50. floe/state.py +311 -0
  51. floe/storage/__init__.py +0 -0
  52. floe/storage/archive.py +31 -0
  53. floe/storage/inputs.py +302 -0
  54. floe/storage/local.py +175 -0
  55. floe/storage/partitioning.py +116 -0
  56. floe/storage/paths.py +69 -0
  57. floe/storage/resolve.py +55 -0
  58. floe/storage/s3.py +182 -0
  59. floe/storage/types.py +33 -0
  60. floe/write/__init__.py +0 -0
  61. floe/write/outputs.py +121 -0
  62. floe_python-0.8.0.dist-info/METADATA +15 -0
  63. floe_python-0.8.0.dist-info/RECORD +66 -0
  64. floe_python-0.8.0.dist-info/WHEEL +4 -0
  65. floe_python-0.8.0.dist-info/entry_points.txt +2 -0
  66. floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
@@ -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