iceberg-loader 0.0.4__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.
- iceberg_loader/__about__.py +4 -0
- iceberg_loader/__init__.py +23 -0
- iceberg_loader/arrow_utils.py +151 -0
- iceberg_loader/iceberg_loader.py +350 -0
- iceberg_loader/maintenance.py +95 -0
- iceberg_loader/py.typed +2 -0
- iceberg_loader/schema.py +322 -0
- iceberg_loader/settings.py +8 -0
- iceberg_loader/strategies.py +100 -0
- iceberg_loader/type_mappings.py +113 -0
- iceberg_loader-0.0.4.dist-info/METADATA +438 -0
- iceberg_loader-0.0.4.dist-info/RECORD +14 -0
- iceberg_loader-0.0.4.dist-info/WHEEL +4 -0
- iceberg_loader-0.0.4.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Ivan Matveev <skioneim@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from iceberg_loader.__about__ import __version__
|
|
6
|
+
from iceberg_loader.iceberg_loader import (
|
|
7
|
+
IcebergLoader,
|
|
8
|
+
LoaderConfig,
|
|
9
|
+
load_batches_to_iceberg,
|
|
10
|
+
load_data_to_iceberg,
|
|
11
|
+
load_ipc_stream_to_iceberg,
|
|
12
|
+
)
|
|
13
|
+
from iceberg_loader.maintenance import expire_snapshots
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
'load_data_to_iceberg',
|
|
17
|
+
'load_batches_to_iceberg',
|
|
18
|
+
'load_ipc_stream_to_iceberg',
|
|
19
|
+
'expire_snapshots',
|
|
20
|
+
'__version__',
|
|
21
|
+
'IcebergLoader',
|
|
22
|
+
'LoaderConfig',
|
|
23
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
import pyarrow.compute as pc
|
|
9
|
+
|
|
10
|
+
_json_dumps = partial(json.dumps, ensure_ascii=False, separators=(',', ':'))
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_memory_pool() -> pa.MemoryPool:
|
|
15
|
+
return pa.system_memory_pool()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_arrow_table_from_data(data: list[dict[str, Any]]) -> pa.Table:
|
|
19
|
+
"""
|
|
20
|
+
Creates a PyArrow Table from a list of dictionaries, handling complex types
|
|
21
|
+
by serializing them to JSON strings if necessary.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
data: A list of dictionaries representing the rows of the table.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A PyArrow Table.
|
|
28
|
+
"""
|
|
29
|
+
if not data:
|
|
30
|
+
return pa.Table.from_arrays([], schema=pa.schema([]))
|
|
31
|
+
|
|
32
|
+
return _create_table_native(data)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _create_table_native(data: list[dict[str, Any]]) -> pa.Table:
|
|
36
|
+
"""
|
|
37
|
+
Manually constructs Arrow arrays from a list of dicts.
|
|
38
|
+
Handles mixed types by falling back to string (JSON) serialization.
|
|
39
|
+
"""
|
|
40
|
+
if not data:
|
|
41
|
+
return pa.Table.from_arrays([], schema=pa.schema([]))
|
|
42
|
+
|
|
43
|
+
# Single pass to collect all unique keys would be better, but
|
|
44
|
+
# for simplicity and memory safety we just iterate.
|
|
45
|
+
# Optimization: using set comprehension is slightly faster than loop.
|
|
46
|
+
all_keys = set().union(*(d.keys() for d in data))
|
|
47
|
+
|
|
48
|
+
arrays = []
|
|
49
|
+
fields = []
|
|
50
|
+
|
|
51
|
+
# Pre-allocate pool
|
|
52
|
+
pool = _get_memory_pool()
|
|
53
|
+
|
|
54
|
+
for key in all_keys:
|
|
55
|
+
column_values: list[str | None] = []
|
|
56
|
+
for item in data:
|
|
57
|
+
value = item.get(key)
|
|
58
|
+
if isinstance(value, dict | list):
|
|
59
|
+
column_values.append(_json_dumps(value))
|
|
60
|
+
else:
|
|
61
|
+
column_values.append(str(value) if value is not None else None)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
array = pa.array(column_values, memory_pool=pool)
|
|
65
|
+
# Use nullable=True by default for robustness
|
|
66
|
+
field = pa.field(key, array.type, nullable=True)
|
|
67
|
+
except (pa.ArrowInvalid, pa.ArrowTypeError):
|
|
68
|
+
# Fallback to string for mixed types
|
|
69
|
+
str_values = [str(v) if v is not None else None for v in column_values]
|
|
70
|
+
array = pa.array(str_values, type=pa.string(), memory_pool=pool)
|
|
71
|
+
field = pa.field(key, pa.string(), nullable=True)
|
|
72
|
+
|
|
73
|
+
# Handle all-null columns
|
|
74
|
+
if pa.types.is_null(array.type):
|
|
75
|
+
array = pa.nulls(len(column_values), type=pa.string(), memory_pool=pool)
|
|
76
|
+
field = pa.field(key, pa.string(), nullable=True)
|
|
77
|
+
|
|
78
|
+
arrays.append(array)
|
|
79
|
+
fields.append(field)
|
|
80
|
+
|
|
81
|
+
return pa.Table.from_arrays(arrays, schema=pa.schema(fields))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def convert_column_type(column: pa.Array, target_type: pa.DataType, column_name: str | None = None) -> pa.Array:
|
|
85
|
+
"""Casts a single column to the target type, handling errors by nulling."""
|
|
86
|
+
if column.type == target_type:
|
|
87
|
+
return column
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
return pc.cast(column, target_type)
|
|
91
|
+
except (ValueError, TypeError, pa.ArrowInvalid):
|
|
92
|
+
logger.warning('Cast failed for column %s to %s, filling nulls', column_name or '<unknown>', target_type)
|
|
93
|
+
return pa.nulls(len(column), type=target_type, memory_pool=_get_memory_pool())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def convert_table_types(table: pa.Table, target_schema: pa.Schema) -> pa.Table:
|
|
97
|
+
"""Convert PyArrow Table to match target schema, casting types and adding missing columns."""
|
|
98
|
+
if table.schema.equals(target_schema):
|
|
99
|
+
return table
|
|
100
|
+
|
|
101
|
+
return _convert_table_types_internal(table, target_schema)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _convert_table_types_internal(table: pa.Table, target_schema: pa.Schema) -> pa.Table:
|
|
105
|
+
source_fields = {field.name: field for field in table.schema}
|
|
106
|
+
new_arrays, new_fields = [], []
|
|
107
|
+
|
|
108
|
+
for field in target_schema:
|
|
109
|
+
if field.name in source_fields:
|
|
110
|
+
source_field = source_fields[field.name]
|
|
111
|
+
column = table[field.name]
|
|
112
|
+
|
|
113
|
+
if source_field.type.equals(field.type):
|
|
114
|
+
new_arrays.append(column)
|
|
115
|
+
new_fields.append(field)
|
|
116
|
+
else:
|
|
117
|
+
new_array = convert_column_type(column, field.type, field.name)
|
|
118
|
+
new_arrays.append(new_array)
|
|
119
|
+
new_fields.append(
|
|
120
|
+
pa.field(field.name, new_array.type, nullable=field.nullable, metadata=field.metadata)
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
# Add missing column as nulls
|
|
124
|
+
null_array = pa.nulls(len(table), type=field.type, memory_pool=_get_memory_pool())
|
|
125
|
+
new_arrays.append(null_array)
|
|
126
|
+
new_fields.append(field)
|
|
127
|
+
|
|
128
|
+
return pa.Table.from_arrays(new_arrays, schema=pa.schema(new_fields))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def create_record_batches_from_dicts(
|
|
132
|
+
data_iterator: Iterator[dict[str, Any]], batch_size: int = 10000
|
|
133
|
+
) -> Iterator[pa.RecordBatch]:
|
|
134
|
+
"""Convert iterator of dicts to PyArrow RecordBatches with specified batch size."""
|
|
135
|
+
batch = []
|
|
136
|
+
|
|
137
|
+
for item in data_iterator:
|
|
138
|
+
batch.append(item)
|
|
139
|
+
|
|
140
|
+
if len(batch) >= batch_size:
|
|
141
|
+
table = create_arrow_table_from_data(batch)
|
|
142
|
+
batches = table.to_batches(max_chunksize=batch_size)
|
|
143
|
+
if batches:
|
|
144
|
+
yield batches[0]
|
|
145
|
+
batch = []
|
|
146
|
+
|
|
147
|
+
if batch:
|
|
148
|
+
table = create_arrow_table_from_data(batch)
|
|
149
|
+
batches = table.to_batches(max_chunksize=len(batch))
|
|
150
|
+
if batches:
|
|
151
|
+
yield batches[0]
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, BinaryIO, Literal
|
|
5
|
+
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
from pyiceberg.catalog import Catalog
|
|
8
|
+
|
|
9
|
+
from iceberg_loader.arrow_utils import convert_table_types
|
|
10
|
+
from iceberg_loader.schema import SchemaManager
|
|
11
|
+
from iceberg_loader.settings import TABLE_PROPERTIES
|
|
12
|
+
from iceberg_loader.strategies import get_write_strategy
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class LoaderConfig:
|
|
19
|
+
"""Defaults for IcebergLoader operations."""
|
|
20
|
+
|
|
21
|
+
write_mode: Literal['overwrite', 'append', 'upsert'] = 'overwrite'
|
|
22
|
+
partition_col: str | None = None
|
|
23
|
+
replace_filter: str | None = None
|
|
24
|
+
schema_evolution: bool = False
|
|
25
|
+
table_properties: dict[str, Any] | None = None
|
|
26
|
+
commit_interval: int = 0
|
|
27
|
+
join_cols: list[str] | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class IcebergLoader:
|
|
31
|
+
"""
|
|
32
|
+
Facade for loading data into Iceberg tables.
|
|
33
|
+
Orchestrates SchemaManager and WriteStrategy to handle complex ingestion scenarios.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
catalog: Catalog,
|
|
39
|
+
table_properties: dict[str, Any] | None = None,
|
|
40
|
+
default_config: LoaderConfig | None = None,
|
|
41
|
+
):
|
|
42
|
+
self.catalog = catalog
|
|
43
|
+
self.table_properties = TABLE_PROPERTIES.copy()
|
|
44
|
+
if table_properties:
|
|
45
|
+
self.table_properties.update(table_properties)
|
|
46
|
+
|
|
47
|
+
self.schema_manager = SchemaManager(self.catalog, self.table_properties)
|
|
48
|
+
self.default_config = default_config or LoaderConfig()
|
|
49
|
+
|
|
50
|
+
def load_data(
|
|
51
|
+
self,
|
|
52
|
+
table_data: pa.Table,
|
|
53
|
+
table_identifier: tuple[str, str],
|
|
54
|
+
write_mode: Literal['overwrite', 'append', 'upsert'] | None = None,
|
|
55
|
+
partition_col: str | None = None,
|
|
56
|
+
replace_filter: str | None = None,
|
|
57
|
+
schema_evolution: bool | None = None,
|
|
58
|
+
table_properties: dict[str, Any] | None = None,
|
|
59
|
+
join_cols: list[str] | None = None,
|
|
60
|
+
config: LoaderConfig | None = None,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""
|
|
63
|
+
Load PyArrow Table into Iceberg table.
|
|
64
|
+
Delegates to load_data_batches for consistency.
|
|
65
|
+
"""
|
|
66
|
+
batches = table_data.to_batches()
|
|
67
|
+
return self.load_data_batches(
|
|
68
|
+
batch_iterator=iter(batches),
|
|
69
|
+
table_identifier=table_identifier,
|
|
70
|
+
write_mode=write_mode,
|
|
71
|
+
partition_col=partition_col,
|
|
72
|
+
replace_filter=replace_filter,
|
|
73
|
+
schema_evolution=schema_evolution,
|
|
74
|
+
table_properties=table_properties,
|
|
75
|
+
join_cols=join_cols,
|
|
76
|
+
config=config,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def load_ipc_stream(
|
|
80
|
+
self,
|
|
81
|
+
stream_source: str | BinaryIO | pa.NativeFile,
|
|
82
|
+
table_identifier: tuple[str, str],
|
|
83
|
+
write_mode: Literal['overwrite', 'append', 'upsert'] | None = None,
|
|
84
|
+
partition_col: str | None = None,
|
|
85
|
+
replace_filter: str | None = None,
|
|
86
|
+
schema_evolution: bool | None = None,
|
|
87
|
+
commit_interval: int | None = None,
|
|
88
|
+
join_cols: list[str] | None = None,
|
|
89
|
+
table_properties: dict[str, Any] | None = None,
|
|
90
|
+
config: LoaderConfig | None = None,
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""Loads data from an Apache Arrow IPC stream source."""
|
|
93
|
+
with pa.ipc.open_stream(stream_source) as reader:
|
|
94
|
+
return self.load_data_batches(
|
|
95
|
+
batch_iterator=reader,
|
|
96
|
+
table_identifier=table_identifier,
|
|
97
|
+
write_mode=write_mode,
|
|
98
|
+
partition_col=partition_col,
|
|
99
|
+
replace_filter=replace_filter,
|
|
100
|
+
schema_evolution=schema_evolution,
|
|
101
|
+
commit_interval=commit_interval,
|
|
102
|
+
join_cols=join_cols,
|
|
103
|
+
table_properties=table_properties,
|
|
104
|
+
config=config,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def load_data_batches(
|
|
108
|
+
self,
|
|
109
|
+
batch_iterator: Iterator[pa.RecordBatch] | pa.RecordBatchReader,
|
|
110
|
+
table_identifier: tuple[str, str],
|
|
111
|
+
write_mode: Literal['overwrite', 'append', 'upsert'] | None = None,
|
|
112
|
+
partition_col: str | None = None,
|
|
113
|
+
replace_filter: str | None = None,
|
|
114
|
+
schema_evolution: bool | None = None,
|
|
115
|
+
commit_interval: int | None = None,
|
|
116
|
+
join_cols: list[str] | None = None,
|
|
117
|
+
table_properties: dict[str, Any] | None = None,
|
|
118
|
+
config: LoaderConfig | None = None,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""
|
|
121
|
+
Main orchestration method.
|
|
122
|
+
Iterates over batches, manages buffers, and delegates writing.
|
|
123
|
+
"""
|
|
124
|
+
total_rows = 0
|
|
125
|
+
batches_processed = 0
|
|
126
|
+
|
|
127
|
+
# Buffer
|
|
128
|
+
pending_batches: list[pa.RecordBatch] = []
|
|
129
|
+
|
|
130
|
+
resolved = self._resolve_config(
|
|
131
|
+
config=config,
|
|
132
|
+
write_mode=write_mode,
|
|
133
|
+
partition_col=partition_col,
|
|
134
|
+
replace_filter=replace_filter,
|
|
135
|
+
schema_evolution=schema_evolution,
|
|
136
|
+
commit_interval=commit_interval,
|
|
137
|
+
join_cols=join_cols,
|
|
138
|
+
table_properties=table_properties,
|
|
139
|
+
)
|
|
140
|
+
self.table_properties = resolved['table_properties']
|
|
141
|
+
strategy = get_write_strategy(resolved['write_mode'], resolved['replace_filter'], resolved['join_cols'])
|
|
142
|
+
effective_schema_evolution = resolved['schema_evolution']
|
|
143
|
+
|
|
144
|
+
# State tracking
|
|
145
|
+
table = None
|
|
146
|
+
is_first_write = True
|
|
147
|
+
new_table_created = False
|
|
148
|
+
|
|
149
|
+
def process_buffer(batches: list[pa.RecordBatch]) -> None:
|
|
150
|
+
nonlocal table, new_table_created, is_first_write, total_rows
|
|
151
|
+
|
|
152
|
+
if not batches:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
combined_table = None
|
|
156
|
+
|
|
157
|
+
if effective_schema_evolution:
|
|
158
|
+
# Try fast path
|
|
159
|
+
try:
|
|
160
|
+
combined_table = pa.Table.from_batches(batches)
|
|
161
|
+
except pa.ArrowInvalid:
|
|
162
|
+
# Mixed schemas in buffer
|
|
163
|
+
logger.info('Mixed schemas in batch buffer. Normalizing...')
|
|
164
|
+
|
|
165
|
+
if table is None:
|
|
166
|
+
table = self.schema_manager.ensure_table_exists(
|
|
167
|
+
table_identifier, batches[0].schema, resolved['partition_col']
|
|
168
|
+
)
|
|
169
|
+
if table.current_snapshot() is None:
|
|
170
|
+
new_table_created = True
|
|
171
|
+
|
|
172
|
+
# Evolve schema for all batches
|
|
173
|
+
for b in batches:
|
|
174
|
+
self.schema_manager.evolve_schema_if_needed(table, b.schema)
|
|
175
|
+
|
|
176
|
+
target_arrow_schema = self.schema_manager.get_arrow_schema(table)
|
|
177
|
+
|
|
178
|
+
# Cast all batches
|
|
179
|
+
normalized_tables = []
|
|
180
|
+
for b in batches:
|
|
181
|
+
t = pa.Table.from_batches([b])
|
|
182
|
+
t_casted = convert_table_types(t, target_arrow_schema)
|
|
183
|
+
normalized_tables.append(t_casted)
|
|
184
|
+
|
|
185
|
+
combined_table = pa.concat_tables(normalized_tables)
|
|
186
|
+
|
|
187
|
+
if combined_table is None:
|
|
188
|
+
combined_table = pa.Table.from_batches(batches)
|
|
189
|
+
|
|
190
|
+
# 1. Ensure Table Exists
|
|
191
|
+
if table is None:
|
|
192
|
+
table = self.schema_manager.ensure_table_exists(
|
|
193
|
+
table_identifier, combined_table.schema, resolved['partition_col']
|
|
194
|
+
)
|
|
195
|
+
if table.current_snapshot() is None:
|
|
196
|
+
new_table_created = True
|
|
197
|
+
|
|
198
|
+
# 2. Schema Evolution
|
|
199
|
+
if effective_schema_evolution:
|
|
200
|
+
self.schema_manager.evolve_schema_if_needed(table, combined_table.schema)
|
|
201
|
+
|
|
202
|
+
# 3. Type Conversion
|
|
203
|
+
target_schema = self.schema_manager.get_arrow_schema(table)
|
|
204
|
+
combined_table = convert_table_types(combined_table, target_schema)
|
|
205
|
+
|
|
206
|
+
# The strategy now handles transaction management internally
|
|
207
|
+
strategy.write(table, combined_table, is_first_write)
|
|
208
|
+
|
|
209
|
+
is_first_write = False
|
|
210
|
+
total_rows += len(combined_table)
|
|
211
|
+
|
|
212
|
+
# Main Loop
|
|
213
|
+
for batch in batch_iterator:
|
|
214
|
+
pending_batches.append(batch)
|
|
215
|
+
batches_processed += 1
|
|
216
|
+
|
|
217
|
+
limit = 1 if resolved['commit_interval'] <= 1 else resolved['commit_interval']
|
|
218
|
+
|
|
219
|
+
if len(pending_batches) >= limit:
|
|
220
|
+
process_buffer(pending_batches)
|
|
221
|
+
pending_batches = []
|
|
222
|
+
|
|
223
|
+
# Flush remainder
|
|
224
|
+
if pending_batches:
|
|
225
|
+
process_buffer(pending_batches)
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
'rows_loaded': total_rows,
|
|
229
|
+
'write_mode': resolved['write_mode'],
|
|
230
|
+
'partition_col': resolved['partition_col'] if resolved['partition_col'] else 'none',
|
|
231
|
+
'table_location': table.location() if table else 'none',
|
|
232
|
+
'snapshot_id': table.current_snapshot().snapshot_id if table and table.current_snapshot() else 'none',
|
|
233
|
+
'batches_processed': batches_processed,
|
|
234
|
+
'new_table_created': new_table_created,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def _resolve_config(
|
|
238
|
+
self,
|
|
239
|
+
config: LoaderConfig | None,
|
|
240
|
+
write_mode: Literal['overwrite', 'append', 'upsert'] | None,
|
|
241
|
+
partition_col: str | None,
|
|
242
|
+
replace_filter: str | None,
|
|
243
|
+
schema_evolution: bool | None,
|
|
244
|
+
commit_interval: int | None,
|
|
245
|
+
join_cols: list[str] | None,
|
|
246
|
+
table_properties: dict[str, Any] | None,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
base = config or self.default_config
|
|
249
|
+
merged_table_props = self.table_properties.copy()
|
|
250
|
+
if base.table_properties:
|
|
251
|
+
merged_table_props.update(base.table_properties)
|
|
252
|
+
if table_properties:
|
|
253
|
+
merged_table_props.update(table_properties)
|
|
254
|
+
return {
|
|
255
|
+
'write_mode': write_mode or base.write_mode,
|
|
256
|
+
'partition_col': partition_col if partition_col is not None else base.partition_col,
|
|
257
|
+
'replace_filter': replace_filter if replace_filter is not None else base.replace_filter,
|
|
258
|
+
'schema_evolution': base.schema_evolution if schema_evolution is None else schema_evolution,
|
|
259
|
+
'commit_interval': base.commit_interval if commit_interval is None else commit_interval,
|
|
260
|
+
'join_cols': join_cols if join_cols is not None else base.join_cols,
|
|
261
|
+
'table_properties': merged_table_props,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Public API functions (thin wrappers)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def load_data_to_iceberg(
|
|
269
|
+
table_data: pa.Table,
|
|
270
|
+
table_identifier: tuple[str, str],
|
|
271
|
+
catalog: Catalog,
|
|
272
|
+
write_mode: Literal['overwrite', 'append', 'upsert'] | None = None,
|
|
273
|
+
partition_col: str | None = None,
|
|
274
|
+
replace_filter: str | None = None,
|
|
275
|
+
schema_evolution: bool | None = None,
|
|
276
|
+
table_properties: dict[str, Any] | None = None,
|
|
277
|
+
join_cols: list[str] | None = None,
|
|
278
|
+
config: LoaderConfig | None = None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
"""Public wrapper around IcebergLoader.load_data using an optional LoaderConfig."""
|
|
281
|
+
loader = IcebergLoader(catalog, table_properties, default_config=config)
|
|
282
|
+
return loader.load_data(
|
|
283
|
+
table_data,
|
|
284
|
+
table_identifier,
|
|
285
|
+
write_mode,
|
|
286
|
+
partition_col,
|
|
287
|
+
replace_filter,
|
|
288
|
+
schema_evolution,
|
|
289
|
+
table_properties,
|
|
290
|
+
join_cols,
|
|
291
|
+
config=config,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def load_batches_to_iceberg(
|
|
296
|
+
batch_iterator: Iterator[pa.RecordBatch] | pa.RecordBatchReader,
|
|
297
|
+
table_identifier: tuple[str, str],
|
|
298
|
+
catalog: Catalog,
|
|
299
|
+
write_mode: Literal['overwrite', 'append', 'upsert'] | None = None,
|
|
300
|
+
partition_col: str | None = None,
|
|
301
|
+
replace_filter: str | None = None,
|
|
302
|
+
schema_evolution: bool | None = None,
|
|
303
|
+
table_properties: dict[str, Any] | None = None,
|
|
304
|
+
commit_interval: int | None = None,
|
|
305
|
+
join_cols: list[str] | None = None,
|
|
306
|
+
config: LoaderConfig | None = None,
|
|
307
|
+
) -> dict[str, Any]:
|
|
308
|
+
"""Public wrapper around IcebergLoader.load_data_batches using an optional LoaderConfig."""
|
|
309
|
+
loader = IcebergLoader(catalog, table_properties, default_config=config)
|
|
310
|
+
return loader.load_data_batches(
|
|
311
|
+
batch_iterator,
|
|
312
|
+
table_identifier,
|
|
313
|
+
write_mode,
|
|
314
|
+
partition_col,
|
|
315
|
+
replace_filter,
|
|
316
|
+
schema_evolution,
|
|
317
|
+
commit_interval,
|
|
318
|
+
join_cols,
|
|
319
|
+
table_properties,
|
|
320
|
+
config,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load_ipc_stream_to_iceberg(
|
|
325
|
+
stream_source: str | BinaryIO | pa.NativeFile,
|
|
326
|
+
table_identifier: tuple[str, str],
|
|
327
|
+
catalog: Catalog,
|
|
328
|
+
write_mode: Literal['overwrite', 'append', 'upsert'] | None = None,
|
|
329
|
+
partition_col: str | None = None,
|
|
330
|
+
replace_filter: str | None = None,
|
|
331
|
+
schema_evolution: bool | None = None,
|
|
332
|
+
table_properties: dict[str, Any] | None = None,
|
|
333
|
+
commit_interval: int | None = None,
|
|
334
|
+
join_cols: list[str] | None = None,
|
|
335
|
+
config: LoaderConfig | None = None,
|
|
336
|
+
) -> dict[str, Any]:
|
|
337
|
+
"""Public wrapper around IcebergLoader.load_ipc_stream using an optional LoaderConfig."""
|
|
338
|
+
loader = IcebergLoader(catalog, table_properties, default_config=config)
|
|
339
|
+
return loader.load_ipc_stream(
|
|
340
|
+
stream_source,
|
|
341
|
+
table_identifier,
|
|
342
|
+
write_mode,
|
|
343
|
+
partition_col,
|
|
344
|
+
replace_filter,
|
|
345
|
+
schema_evolution,
|
|
346
|
+
commit_interval,
|
|
347
|
+
join_cols,
|
|
348
|
+
table_properties,
|
|
349
|
+
config,
|
|
350
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from pyiceberg.exceptions import CommitFailedException as IcebergError
|
|
7
|
+
except ImportError: # pragma: no cover - fallback for environments without pyiceberg
|
|
8
|
+
|
|
9
|
+
class IcebergError(Exception): # type: ignore[no-redef]
|
|
10
|
+
"""Fallback when pyiceberg is not installed."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SnapshotMaintenance:
|
|
17
|
+
"""
|
|
18
|
+
Manages Iceberg table snapshot maintenance operations.
|
|
19
|
+
|
|
20
|
+
Provides functionality to expire old snapshots and prevent metadata bloat.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def expire_snapshots(self, table: Any, keep_last: int = 1, older_than_ms: int | None = None) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Expire old snapshots to prevent metadata issues.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
table: Iceberg table instance.
|
|
29
|
+
keep_last: How many most recent snapshots to keep (default: 1).
|
|
30
|
+
older_than_ms: Optional timestamp in milliseconds; expire snapshots strictly older than this moment.
|
|
31
|
+
If provided, it overrides keep_last logic.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
table.refresh()
|
|
35
|
+
|
|
36
|
+
snapshots = list(table.snapshots())
|
|
37
|
+
logger.info('Found %d snapshots', len(snapshots))
|
|
38
|
+
|
|
39
|
+
if len(snapshots) == 0:
|
|
40
|
+
logger.info('No snapshots found, nothing to fix')
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
sorted_snapshots = sorted(snapshots, key=lambda s: s.timestamp_ms)
|
|
44
|
+
current_snapshot = table.current_snapshot()
|
|
45
|
+
|
|
46
|
+
logger.info('Snapshot details (sorted by timestamp):')
|
|
47
|
+
for i, snap in enumerate(sorted_snapshots):
|
|
48
|
+
is_current = current_snapshot and snap.snapshot_id == current_snapshot.snapshot_id
|
|
49
|
+
marker = ' <-- CURRENT' if is_current else ''
|
|
50
|
+
logger.info(' %d. ID=%s, timestamp=%s%s', i + 1, snap.snapshot_id, snap.timestamp_ms, marker)
|
|
51
|
+
|
|
52
|
+
expire = table.maintenance.expire_snapshots()
|
|
53
|
+
|
|
54
|
+
# Determine cutoff strategy
|
|
55
|
+
if older_than_ms is not None:
|
|
56
|
+
cutoff_datetime = datetime.fromtimestamp(older_than_ms / 1000.0)
|
|
57
|
+
logger.info('Expiring snapshots older than %s (ms=%d)', cutoff_datetime, older_than_ms)
|
|
58
|
+
expire = expire.older_than(cutoff_datetime)
|
|
59
|
+
else:
|
|
60
|
+
if keep_last < 0:
|
|
61
|
+
logger.info('keep_last < 0 specified, skipping expiration')
|
|
62
|
+
return
|
|
63
|
+
if len(snapshots) <= keep_last:
|
|
64
|
+
logger.info('Table has %d snapshots, keep_last=%d → nothing to expire', len(snapshots), keep_last)
|
|
65
|
+
return
|
|
66
|
+
cutoff_snapshot = sorted_snapshots[-keep_last]
|
|
67
|
+
cutoff_datetime = datetime.fromtimestamp((cutoff_snapshot.timestamp_ms - 1) / 1000.0)
|
|
68
|
+
logger.info(
|
|
69
|
+
'Expiring snapshots older than %s to keep last %d snapshot(s)',
|
|
70
|
+
cutoff_datetime,
|
|
71
|
+
keep_last,
|
|
72
|
+
)
|
|
73
|
+
expire = expire.older_than(cutoff_datetime)
|
|
74
|
+
|
|
75
|
+
before = len(snapshots)
|
|
76
|
+
expire.commit()
|
|
77
|
+
table.refresh()
|
|
78
|
+
remaining_snapshots = list(table.snapshots())
|
|
79
|
+
after = len(remaining_snapshots)
|
|
80
|
+
logger.info('Successfully expired snapshots: %d removed, %d remaining', before - after, after)
|
|
81
|
+
|
|
82
|
+
except (IcebergError, OSError, ValueError, RuntimeError) as e:
|
|
83
|
+
logger.warning('Failed to expire snapshots for table %s: %s', table.name(), e)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def expire_snapshots(table: Any, keep_last: int = 1, older_than_ms: int | None = None) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Convenience function to expire snapshots without instantiating SnapshotMaintenance.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
table: Iceberg table instance.
|
|
92
|
+
keep_last: How many most recent snapshots to keep (default: 1).
|
|
93
|
+
older_than_ms: Optional timestamp in milliseconds; expire snapshots strictly older than this moment.
|
|
94
|
+
"""
|
|
95
|
+
SnapshotMaintenance().expire_snapshots(table, keep_last=keep_last, older_than_ms=older_than_ms)
|
iceberg_loader/py.typed
ADDED