floe-python 0.8.8__py3-none-any.whl → 0.9.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/checks/unique.py +4 -44
- floe/config/profile.py +29 -21
- floe/iceberg/catalog.py +53 -45
- floe/iceberg/compact.py +3 -1
- floe/iceberg/sink.py +11 -7
- floe/iceberg/write.py +46 -9
- floe/manifest.py +4 -7
- floe/run/__init__.py +10 -6
- floe/run/entity.py +36 -13
- floe/state.py +34 -8
- floe/storage/archive.py +1 -1
- floe/storage/local.py +5 -1
- floe/storage/partitioning.py +16 -6
- floe/storage/s3.py +32 -2
- floe/storage/types.py +4 -1
- {floe_python-0.8.8.dist-info → floe_python-0.9.0.dist-info}/METADATA +1 -1
- {floe_python-0.8.8.dist-info → floe_python-0.9.0.dist-info}/RECORD +20 -20
- {floe_python-0.8.8.dist-info → floe_python-0.9.0.dist-info}/WHEEL +0 -0
- {floe_python-0.8.8.dist-info → floe_python-0.9.0.dist-info}/entry_points.txt +0 -0
- {floe_python-0.8.8.dist-info → floe_python-0.9.0.dist-info}/licenses/LICENSE +0 -0
floe/checks/unique.py
CHANGED
|
@@ -17,7 +17,6 @@ from floe.checks.rows import RowCheck
|
|
|
17
17
|
|
|
18
18
|
SAMPLE_LIMIT = 5
|
|
19
19
|
_SEEN = "__floe_seen"
|
|
20
|
-
_DEBUG = "__floe_debug"
|
|
21
20
|
_COUNT = "__floe_count"
|
|
22
21
|
_BITS = "_bits" # suffix of the bit-pattern column that keys a float column
|
|
23
22
|
|
|
@@ -56,45 +55,11 @@ def resolve_unique_keys(
|
|
|
56
55
|
return resolved
|
|
57
56
|
|
|
58
57
|
|
|
59
|
-
# `str` Debug; ponytail: non-ASCII non-printable characters (Rust `\\u{..}`) are left raw
|
|
60
|
-
_STR_ESCAPES = {
|
|
61
|
-
"\\": "\\\\",
|
|
62
|
-
'"': '\\"',
|
|
63
|
-
"\n": "\\n",
|
|
64
|
-
"\r": "\\r",
|
|
65
|
-
"\t": "\\t",
|
|
66
|
-
"\0": "\\0",
|
|
67
|
-
**{chr(i): f"\\u{{{i:x}}}" for i in [*range(1, 32), 127] if chr(i) not in "\n\r\t"},
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
58
|
def _float_bits(s: pl.Series) -> pl.Series:
|
|
72
59
|
"""Rust's `to_bits()`: Polars cannot reinterpret a float, so view the Arrow buffer."""
|
|
73
60
|
return pl.Series(s.cast(pl.Float64).to_arrow().view(pa.uint64()))
|
|
74
61
|
|
|
75
62
|
|
|
76
|
-
def _debug(name: str, dtype: pl.DataType) -> pl.Expr:
|
|
77
|
-
"""Rust's `{:?}` of the `UniqueKey` variant: the sample tie-break sorts on these strings."""
|
|
78
|
-
c = pl.col(name)
|
|
79
|
-
if dtype == pl.String:
|
|
80
|
-
escaped = c
|
|
81
|
-
for raw, esc in _STR_ESCAPES.items():
|
|
82
|
-
escaped = escaped.str.replace_all(raw, esc, literal=True)
|
|
83
|
-
return pl.format('String("{}")', escaped)
|
|
84
|
-
if dtype == pl.Boolean:
|
|
85
|
-
return pl.format("Bool({})", c.cast(pl.String))
|
|
86
|
-
if dtype.is_signed_integer():
|
|
87
|
-
return pl.format("I64({})", c.cast(pl.String))
|
|
88
|
-
if dtype.is_unsigned_integer():
|
|
89
|
-
return pl.format("U64({})", c.cast(pl.String))
|
|
90
|
-
if dtype.is_float():
|
|
91
|
-
return pl.format("F64({})", pl.col(name + _BITS).cast(pl.String))
|
|
92
|
-
if isinstance(dtype, pl.Datetime): # chrono prints the fraction only when not zero
|
|
93
|
-
return pl.format('Other("{}")', c.dt.to_string("%Y-%m-%d %H:%M:%S%.f"))
|
|
94
|
-
# ponytail: date keys use `Other(display)`, unverified against Rust; ties may order differently
|
|
95
|
-
return pl.format('Other("{}")', c.cast(pl.String))
|
|
96
|
-
|
|
97
|
-
|
|
98
63
|
def _text(value: object) -> str:
|
|
99
64
|
"""Rust's `to_string` of a key value."""
|
|
100
65
|
if isinstance(value, bool):
|
|
@@ -221,7 +186,7 @@ class UniqueTracker:
|
|
|
221
186
|
return RowCheck("unique", name, message, dup)
|
|
222
187
|
|
|
223
188
|
def results(self) -> list[UniqueResult]:
|
|
224
|
-
"""Per constraint, the top `SAMPLE_LIMIT` keys by count, ties by
|
|
189
|
+
"""Per constraint, the top `SAMPLE_LIMIT` keys by count, ties by the key values (numbers numerically, strings by code point)."""
|
|
225
190
|
return [self._result(c) for c in self._constraints]
|
|
226
191
|
|
|
227
192
|
def _result(self, c: _Constraint) -> UniqueResult:
|
|
@@ -229,14 +194,9 @@ class UniqueTracker:
|
|
|
229
194
|
return UniqueResult(c.columns, 0, [])
|
|
230
195
|
parts = pl.concat(c.dup_counts)
|
|
231
196
|
counts = parts.group_by(parts.columns[:-1]).agg(pl.col(_COUNT).sum())
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
)
|
|
236
|
-
top = (
|
|
237
|
-
counts.with_columns(debug.alias(_DEBUG))
|
|
238
|
-
.sort([_COUNT, _DEBUG], descending=[True, False])
|
|
239
|
-
.head(SAMPLE_LIMIT)
|
|
197
|
+
by = [_COUNT, *counts.columns[:-1]]
|
|
198
|
+
top = counts.sort(by, descending=[True] + [False] * (len(by) - 1)).head(
|
|
199
|
+
SAMPLE_LIMIT
|
|
240
200
|
)
|
|
241
201
|
samples = [ # at most SAMPLE_LIMIT rows
|
|
242
202
|
UniqueSample(
|
floe/config/profile.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Environment profiles, ported from `profile/parse.rs` and `profile/validate.rs`."""
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
import os
|
|
4
5
|
from collections.abc import Callable, Mapping
|
|
5
6
|
from dataclasses import dataclass
|
|
@@ -45,7 +46,8 @@ CONTRACT = {
|
|
|
45
46
|
@dataclass(frozen=True)
|
|
46
47
|
class ProfileConfig:
|
|
47
48
|
name: str
|
|
48
|
-
|
|
49
|
+
# `type` and every field, None when unset; then a kubernetes_job's pass-through keys
|
|
50
|
+
runner: dict[str, object]
|
|
49
51
|
variables: dict[str, str]
|
|
50
52
|
catalogs: CatalogsConfig | None
|
|
51
53
|
storages: StoragesConfig | None
|
|
@@ -157,17 +159,6 @@ def _str_keyed(value: object, where: str) -> dict[str, object]:
|
|
|
157
159
|
return cast(dict[str, object], hash)
|
|
158
160
|
|
|
159
161
|
|
|
160
|
-
def _object(hash: Hash, key: str, ctx: str) -> dict[str, object] | None:
|
|
161
|
-
return _str_keyed(hash[key], f"{ctx}.{key}") if key in hash else None
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def _objects(hash: Hash, key: str, ctx: str) -> list[dict[str, object]] | None:
|
|
165
|
-
if key not in hash:
|
|
166
|
-
return None
|
|
167
|
-
where = f"{ctx}.{key}"
|
|
168
|
-
return [_str_keyed(item, f"{where}[]") for item in yaml_array(hash[key], where)]
|
|
169
|
-
|
|
170
|
-
|
|
171
162
|
def _auth(hash: Hash, key: str, ctx: str) -> dict[str, str | None] | None:
|
|
172
163
|
if key not in hash:
|
|
173
164
|
return None
|
|
@@ -200,24 +191,41 @@ _RUNNER_FIELDS: tuple[tuple[str, Reader], ...] = (
|
|
|
200
191
|
("env_parameters", _map),
|
|
201
192
|
("job_scope", _job_scope),
|
|
202
193
|
("domain_resources", _domain_resources),
|
|
203
|
-
("labels", _map),
|
|
204
|
-
("annotations", _map),
|
|
205
|
-
("image_pull_policy", _string),
|
|
206
|
-
("security_context", _object),
|
|
207
|
-
("node_selector", _map),
|
|
208
|
-
("tolerations", _objects),
|
|
209
|
-
("backoff_limit", _u64),
|
|
210
|
-
("env_from", _objects),
|
|
211
194
|
)
|
|
195
|
+
# Legacy: the keys any runner type accepted before #1065. A non-k8s runner still accepts and ignores them,
|
|
196
|
+
# so a profile copied from a kubernetes_job keeps loading; they are not validated here.
|
|
197
|
+
_LEGACY_IGNORED = (
|
|
198
|
+
*("labels", "annotations", "image_pull_policy", "security_context"),
|
|
199
|
+
*("node_selector", "tolerations", "backoff_limit", "env_from"),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _json(value: object, where: str) -> object:
|
|
204
|
+
"""A pass-through value as written, once JSON can carry it: string mapping keys, finite numbers."""
|
|
205
|
+
if isinstance(value, dict):
|
|
206
|
+
hash = _str_keyed(cast(object, value), where)
|
|
207
|
+
return {k: _json(v, f"{where}.{k}") for k, v in hash.items()}
|
|
208
|
+
if isinstance(value, list):
|
|
209
|
+
return [_json(v, f"{where}[]") for v in cast(list[object], value)]
|
|
210
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
211
|
+
raise FloeConfigError(f"{where} must be a finite number")
|
|
212
|
+
return value
|
|
212
213
|
|
|
213
214
|
|
|
214
215
|
def _runner(value: object) -> dict[str, object]:
|
|
215
216
|
ctx = "profile.execution.runner"
|
|
216
217
|
hash = yaml_hash(value, ctx)
|
|
217
|
-
|
|
218
|
+
known = ("type", *(name for name, _ in _RUNNER_FIELDS))
|
|
219
|
+
# #1065: a kubernetes_job's other keys are pod options its orchestrator validates; they pass through
|
|
220
|
+
open_ = hash.get("type") == "kubernetes_job"
|
|
221
|
+
validate_known_keys(hash, ctx, hash.keys() if open_ else (*known, *_LEGACY_IGNORED))
|
|
218
222
|
read: dict[str, object] = {"type": _string(hash, "type", ctx, True)}
|
|
219
223
|
for name, reader in _RUNNER_FIELDS:
|
|
220
224
|
read[name] = reader(hash, name, ctx)
|
|
225
|
+
if open_:
|
|
226
|
+
read |= {
|
|
227
|
+
str(k): _json(v, f"{ctx}.{k}") for k, v in hash.items() if k not in known
|
|
228
|
+
}
|
|
221
229
|
return read
|
|
222
230
|
|
|
223
231
|
|
floe/iceberg/catalog.py
CHANGED
|
@@ -12,7 +12,11 @@ from urllib.parse import urlsplit
|
|
|
12
12
|
|
|
13
13
|
import duckdb
|
|
14
14
|
from pyiceberg.catalog import Catalog, load_catalog
|
|
15
|
-
from pyiceberg.exceptions import
|
|
15
|
+
from pyiceberg.exceptions import (
|
|
16
|
+
NamespaceAlreadyExistsError,
|
|
17
|
+
NoSuchNamespaceError,
|
|
18
|
+
NoSuchTableError,
|
|
19
|
+
)
|
|
16
20
|
from pyiceberg.partitioning import PartitionField, PartitionSpec
|
|
17
21
|
from pyiceberg.schema import Schema
|
|
18
22
|
from pyiceberg.table import Table
|
|
@@ -345,55 +349,59 @@ def ensure_table(
|
|
|
345
349
|
|
|
346
350
|
With `add_columns`, columns new in `schema` are added as optional (D13) and returned by name; partition changes stay rejected.
|
|
347
351
|
"""
|
|
348
|
-
ident = (namespace,)
|
|
349
|
-
with run_error("rest catalog namespace_exists check failed"):
|
|
350
|
-
namespace_exists = catalog.namespace_exists(ident)
|
|
351
|
-
if not namespace_exists:
|
|
352
|
-
if not create_namespace:
|
|
353
|
-
raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
|
|
354
|
-
with (
|
|
355
|
-
run_error("rest catalog namespace create failed"),
|
|
356
|
-
suppress(NamespaceAlreadyExistsError),
|
|
357
|
-
): # another entity or process created it first
|
|
358
|
-
catalog.create_namespace(ident)
|
|
359
352
|
identifier = (namespace, name)
|
|
360
|
-
with run_error("rest catalog
|
|
361
|
-
|
|
362
|
-
if exists:
|
|
363
|
-
with run_error("rest catalog load_table failed"):
|
|
353
|
+
with run_error("rest catalog load_table failed"):
|
|
354
|
+
try:
|
|
364
355
|
table = catalog.load_table(identifier)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
356
|
+
except (
|
|
357
|
+
NoSuchTableError,
|
|
358
|
+
NoSuchNamespaceError,
|
|
359
|
+
): # fmt: skip -- a missing namespace raises either, per catalog
|
|
360
|
+
table = None
|
|
361
|
+
if table is None:
|
|
362
|
+
ident = (namespace,)
|
|
363
|
+
with run_error("rest catalog namespace_exists check failed"):
|
|
364
|
+
namespace_exists = catalog.namespace_exists(ident)
|
|
365
|
+
if not namespace_exists:
|
|
366
|
+
if not create_namespace:
|
|
367
|
+
raise FloeRunError(f"entity.name={entity} iceberg namespace {namespace} does not exist and create_database_if_missing is false") # fmt: skip
|
|
368
|
+
with (
|
|
369
|
+
run_error("rest catalog namespace create failed"),
|
|
370
|
+
suppress(NamespaceAlreadyExistsError),
|
|
371
|
+
): # another entity or process created it first
|
|
372
|
+
catalog.create_namespace(ident)
|
|
373
|
+
spec = (
|
|
375
374
|
None
|
|
376
375
|
if partition_fields is None
|
|
377
|
-
else build_partition_spec(
|
|
378
|
-
entity,
|
|
376
|
+
else build_partition_spec(schema, partition_fields, entity)
|
|
379
377
|
)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
378
|
+
with run_error("rest catalog create_table failed"):
|
|
379
|
+
created = catalog.create_table( # pyright: ignore[reportUnknownMemberType] -- PyIceberg's `schema` is `Schema | Unknown`
|
|
380
|
+
identifier,
|
|
381
|
+
schema,
|
|
382
|
+
location=location,
|
|
383
|
+
partition_spec=spec if spec is not None else PartitionSpec(),
|
|
384
|
+
)
|
|
385
|
+
return created, []
|
|
386
|
+
added: list[NestedField] = []
|
|
387
|
+
if schema_evolution == "add_columns":
|
|
388
|
+
added = additive_columns(table.schema(), schema, entity)
|
|
389
|
+
# field ids of `schema` may differ from the table's once columns are added
|
|
390
|
+
check_schema = table.schema()
|
|
391
|
+
else:
|
|
392
|
+
check_schema_matches(table.schema(), schema, entity)
|
|
393
|
+
check_schema = schema
|
|
394
|
+
check_partition_spec_matches(
|
|
395
|
+
table.spec(),
|
|
388
396
|
None
|
|
389
397
|
if partition_fields is None
|
|
390
|
-
else build_partition_spec(
|
|
398
|
+
else build_partition_spec(check_schema, partition_fields, entity),
|
|
399
|
+
entity,
|
|
391
400
|
)
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
return created, []
|
|
401
|
+
if added:
|
|
402
|
+
with run_error("rest catalog update_schema failed"):
|
|
403
|
+
with table.update_schema() as update:
|
|
404
|
+
for field in added:
|
|
405
|
+
update.add_column(field.name, field.field_type, required=False)
|
|
406
|
+
table.refresh()
|
|
407
|
+
return table, [f.name for f in added]
|
floe/iceberg/compact.py
CHANGED
|
@@ -71,7 +71,9 @@ class _RewriteFiles(_OverwriteFiles):
|
|
|
71
71
|
for e in entries:
|
|
72
72
|
writer.delete(e) if e.data_file in self._deleted_data_files else writer.existing(e) # fmt: skip
|
|
73
73
|
out.append(writer.to_manifest_file())
|
|
74
|
-
return
|
|
74
|
+
return [
|
|
75
|
+
m for m in out if m.has_added_files() or m.has_existing_files()
|
|
76
|
+
] # #1136: as `_LiveOverwrite`
|
|
75
77
|
|
|
76
78
|
def _summary(self, snapshot_properties: Mapping[str, str] = {}) -> Summary:
|
|
77
79
|
# built as an OVERWRITE: PyIceberg 0.12's `update_snapshot_summaries` rejects REPLACE
|
floe/iceberg/sink.py
CHANGED
|
@@ -15,6 +15,7 @@ from typing import Any
|
|
|
15
15
|
import polars as pl
|
|
16
16
|
import pyarrow as pa
|
|
17
17
|
from pyiceberg.catalog import Catalog
|
|
18
|
+
from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchTableError
|
|
18
19
|
from pyiceberg.schema import Schema, assign_fresh_schema_ids
|
|
19
20
|
from pyiceberg.table import Table
|
|
20
21
|
|
|
@@ -39,6 +40,7 @@ from floe.iceberg.merge import merge_scd1, merge_scd2
|
|
|
39
40
|
from floe.iceberg.write import (
|
|
40
41
|
SMALL_FILE_THRESHOLD_BYTES,
|
|
41
42
|
IcebergWriteResult,
|
|
43
|
+
parquet_properties,
|
|
42
44
|
write_table,
|
|
43
45
|
)
|
|
44
46
|
from floe.storage.inputs import InputFile
|
|
@@ -176,13 +178,15 @@ def write_iceberg(
|
|
|
176
178
|
primary_key = entity.schema_.primary_key or [] # validated non-empty for merges
|
|
177
179
|
key = [output.get(k.strip(), k.strip()) for k in primary_key]
|
|
178
180
|
identifier = (target.namespace, target.table)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
exists = entity.sink.write_mode in ("merge_scd1", "merge_scd2") and catalog.table_exists(identifier) # fmt: skip
|
|
182
|
-
if exists:
|
|
181
|
+
# the configured mode: a refreshed merge writes as overwrite and keeps the key guard (#1091)
|
|
182
|
+
if entity.sink.write_mode in ("merge_scd1", "merge_scd2"):
|
|
183
183
|
with run_error("rest catalog load_table failed"):
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
try:
|
|
185
|
+
existing = set(catalog.load_table(identifier).schema().column_names)
|
|
186
|
+
except (NoSuchTableError, NoSuchNamespaceError):
|
|
187
|
+
existing = None
|
|
188
|
+
# Rust `merge/shared.rs`
|
|
189
|
+
if existing is not None and (added := [k for k in key if k not in existing]):
|
|
186
190
|
raise FloeRunError(f"entity.name={entity.name} iceberg schema evolution failed: merge key columns cannot be added: {', '.join(added)}") # fmt: skip
|
|
187
191
|
table, added = ensure_table(
|
|
188
192
|
catalog, target.namespace, target.table, schema, entity.name, target.location.removeprefix("local://"),
|
|
@@ -215,7 +219,7 @@ def write_iceberg(
|
|
|
215
219
|
threshold = SMALL_FILE_THRESHOLD_BYTES if size is None else max(1, size // 2)
|
|
216
220
|
append = "overwrite" if mode == "overwrite" else "append"
|
|
217
221
|
unique = [[output.get(c, c) for c in k] for k in resolve_unique_keys(entity.schema_.unique_keys, [c.name for c in entity.schema_.columns if c.unique], entity.schema_.primary_key)] # fmt: skip
|
|
218
|
-
result = write_table(table, reader, append, run_id, digest, threshold, unique_keys=unique) # fmt: skip
|
|
222
|
+
result = write_table(table, reader, append, run_id, digest, threshold, unique_keys=unique, parquet=parquet_properties(options and options.compression, options and options.row_group_size)) # fmt: skip
|
|
219
223
|
ids = {"table_version": result.metadata_version, "snapshot_id": result.snapshot_id}
|
|
220
224
|
fields = {k: v for k, v in ids.items() if v is not None} | {
|
|
221
225
|
"iceberg_catalog_name": target.catalog.name,
|
floe/iceberg/write.py
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import itertools
|
|
7
7
|
import uuid
|
|
8
|
-
from collections.abc import Callable, Iterator, Sequence
|
|
8
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
9
9
|
from dataclasses import dataclass, replace
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from pathlib import PurePosixPath
|
|
@@ -18,9 +18,17 @@ from pyiceberg.io.pyarrow import (
|
|
|
18
18
|
_check_pyarrow_schema_compatible, # pyright: ignore[reportPrivateUsage] -- same check `Transaction.append` makes
|
|
19
19
|
_dataframe_to_data_files, # pyright: ignore[reportPrivateUsage] -- feeds one snapshot producer from many chunks
|
|
20
20
|
)
|
|
21
|
-
from pyiceberg.manifest import
|
|
21
|
+
from pyiceberg.manifest import (
|
|
22
|
+
DataFile,
|
|
23
|
+
DataFileContent,
|
|
24
|
+
ManifestEntryStatus,
|
|
25
|
+
ManifestFile,
|
|
26
|
+
)
|
|
22
27
|
from pyiceberg.table import Table, TableProperties
|
|
23
|
-
from pyiceberg.table.snapshots import ancestors_of
|
|
28
|
+
from pyiceberg.table.snapshots import Operation, ancestors_of
|
|
29
|
+
from pyiceberg.table.update.snapshot import (
|
|
30
|
+
_OverwriteFiles, # pyright: ignore[reportPrivateUsage] -- PyIceberg 0.12 has no public hook on the manifest list
|
|
31
|
+
)
|
|
24
32
|
|
|
25
33
|
from floe.errors import FloeRunError
|
|
26
34
|
from floe.iceberg.catalog import run_error
|
|
@@ -55,6 +63,14 @@ class IcebergWriteResult:
|
|
|
55
63
|
)
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
class _LiveOverwrite(_OverwriteFiles):
|
|
67
|
+
"""PyIceberg 0.12 keeps the all-DELETED manifest of every earlier overwrite in the manifest list, one more per
|
|
68
|
+
overwrite, so the scan and the commit read more manifests each time (#1132); Java drops them, so do we."""
|
|
69
|
+
|
|
70
|
+
def _existing_manifests(self) -> list[ManifestFile]:
|
|
71
|
+
return [m for m in super()._existing_manifests() if m.has_added_files() or m.has_existing_files()] # fmt: skip
|
|
72
|
+
|
|
73
|
+
|
|
58
74
|
def _chunks(reader: pa.RecordBatchReader, chunk_rows: int) -> Iterator[pa.Table]:
|
|
59
75
|
pending: list[pa.RecordBatch] = []
|
|
60
76
|
rows = 0
|
|
@@ -72,6 +88,16 @@ def _chunks(reader: pa.RecordBatchReader, chunk_rows: int) -> Iterator[pa.Table]
|
|
|
72
88
|
yield pa.Table.from_batches(pending, reader.schema)
|
|
73
89
|
|
|
74
90
|
|
|
91
|
+
def parquet_properties(
|
|
92
|
+
compression: str | None, row_group_size: int | None
|
|
93
|
+
) -> dict[str, str]:
|
|
94
|
+
"""The sink's `compression` and `row_group_size` as PyIceberg's write properties; unset keeps the table's."""
|
|
95
|
+
props = {TableProperties.PARQUET_COMPRESSION: compression} if compression else {}
|
|
96
|
+
if row_group_size:
|
|
97
|
+
props[TableProperties.PARQUET_ROW_GROUP_LIMIT] = str(row_group_size)
|
|
98
|
+
return props
|
|
99
|
+
|
|
100
|
+
|
|
75
101
|
def _metadata_version(location: str | None) -> int | None:
|
|
76
102
|
prefix = PurePosixPath(location or "").name.split("-", 1)[0]
|
|
77
103
|
return int(prefix) if prefix.isdigit() else None
|
|
@@ -86,22 +112,25 @@ def write_table(
|
|
|
86
112
|
small_file_threshold_bytes: int = SMALL_FILE_THRESHOLD_BYTES,
|
|
87
113
|
chunk_rows: int = CHUNK_ROWS,
|
|
88
114
|
unique_keys: Sequence[Sequence[str]] = (),
|
|
115
|
+
parquet: Mapping[str, str] | None = None,
|
|
89
116
|
) -> IcebergWriteResult:
|
|
90
117
|
"""Data files are written chunk by chunk (partitioned tables split each chunk), then committed once.
|
|
91
118
|
|
|
92
119
|
Overwrite replaces every existing file in the same snapshot. An append of no rows commits nothing.
|
|
93
120
|
On a conflict an append checks `unique_keys` (table column names) against the files added since the base
|
|
94
121
|
snapshot and fails closed, committing nothing, when a rival already holds a key (#549).
|
|
122
|
+
`parquet` holds `write.parquet.*` properties for these data files only; the table's own properties are not changed.
|
|
95
123
|
"""
|
|
96
124
|
with run_error("iceberg data write failed"):
|
|
97
125
|
_check_pyarrow_schema_compatible(table.schema(), reader.schema)
|
|
126
|
+
metadata = table.metadata.model_copy(update={"properties": {**table.properties, **(parquet or {})}}) # fmt: skip
|
|
98
127
|
counter = itertools.count()
|
|
99
128
|
write_uuid = uuid.uuid4()
|
|
100
129
|
data_files: list[DataFile] = [
|
|
101
130
|
data_file
|
|
102
131
|
for chunk in _chunks(reader, chunk_rows)
|
|
103
132
|
for data_file in _dataframe_to_data_files(
|
|
104
|
-
|
|
133
|
+
metadata, chunk, table.io, write_uuid, counter
|
|
105
134
|
)
|
|
106
135
|
]
|
|
107
136
|
digest = (
|
|
@@ -131,13 +160,21 @@ def write_table(
|
|
|
131
160
|
# PyIceberg retries a 409 itself (refresh, rebuild) and would skip the guard; in memory only, never sent
|
|
132
161
|
table.metadata = table.metadata.model_copy(update={"properties": {**table.properties, TableProperties.COMMIT_NUM_RETRIES: "0"}}) # fmt: skip
|
|
133
162
|
tx = table.transaction()
|
|
134
|
-
|
|
135
|
-
snapshot_properties={RUN_ID: run_id, INPUT_HASH: digest}
|
|
136
|
-
)
|
|
163
|
+
props = {RUN_ID: run_id, INPUT_HASH: digest}
|
|
137
164
|
with (
|
|
138
|
-
|
|
165
|
+
_LiveOverwrite(
|
|
166
|
+
operation=Operation.OVERWRITE
|
|
167
|
+
if table.current_snapshot()
|
|
168
|
+
else Operation.APPEND,
|
|
169
|
+
transaction=tx,
|
|
170
|
+
io=table.io,
|
|
171
|
+
branch="main",
|
|
172
|
+
snapshot_properties=props,
|
|
173
|
+
)
|
|
139
174
|
if mode == "overwrite"
|
|
140
|
-
else
|
|
175
|
+
else tx.update_snapshot(
|
|
176
|
+
snapshot_properties=props
|
|
177
|
+
).fast_append() as snap
|
|
141
178
|
):
|
|
142
179
|
for old in replaced:
|
|
143
180
|
snap.delete_data_file(old)
|
floe/manifest.py
CHANGED
|
@@ -275,10 +275,6 @@ _RUNNER_FIELDS = (
|
|
|
275
275
|
*("resources", "env", "workspace_url", "existing_cluster_id", "config_uri"),
|
|
276
276
|
*("python_file_uri", "job_name", "auth", "env_parameters"),
|
|
277
277
|
)
|
|
278
|
-
_PYTHON_RUNNER_FIELDS = ( # carried only when set
|
|
279
|
-
*("job_scope", "domain_resources", "labels", "annotations", "image_pull_policy"),
|
|
280
|
-
*("security_context", "node_selector", "tolerations", "backoff_limit", "env_from"),
|
|
281
|
-
)
|
|
282
278
|
|
|
283
279
|
|
|
284
280
|
# Rust `runners_contract`: the fields each profile runner type carries into the manifest
|
|
@@ -303,9 +299,10 @@ def _runners(profile: ProfileConfig | None) -> Json:
|
|
|
303
299
|
v = dict(sorted(cast(dict[str, str], v).items()))
|
|
304
300
|
runner[k] = v
|
|
305
301
|
if kind == "kubernetes_job":
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
302
|
+
# `job_scope`, `domain_resources` and the pod options (#1065), only when set
|
|
303
|
+
runner |= {
|
|
304
|
+
k: v for k, v in profile.runner.items() if k not in runner and v is not None
|
|
305
|
+
}
|
|
309
306
|
if kind == "databricks_job":
|
|
310
307
|
runner["job_name"] = runner["job_name"] or "floe-{domain}-{env}"
|
|
311
308
|
return {"default": "default", "definitions": {"default": runner}}
|
floe/run/__init__.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
4
|
from collections.abc import Sequence
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from datetime import UTC, datetime
|
|
7
8
|
from pathlib import Path
|
|
@@ -158,12 +159,15 @@ def run(
|
|
|
158
159
|
|
|
159
160
|
emit("run_started", config=config, report_base=report_path)
|
|
160
161
|
# Rust resolves every entity's inputs before the first entity_started (#864); run_entity reuses them
|
|
161
|
-
|
|
162
|
-
for e in selected:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
# D24: a full refresh may rebuild from the archive alone, so run_entity decides
|
|
163
|
+
to_list = [e for e in selected if not (full_refresh and e.sink.archive and e.incremental_mode in ("none", "archive"))] # fmt: skip
|
|
164
|
+
# `map` yields in config order, so the first failure in config order is raised
|
|
165
|
+
if workers > 1 and len(to_list) > 1:
|
|
166
|
+
with ThreadPoolExecutor(workers, thread_name_prefix="floe-list") as pool:
|
|
167
|
+
found = list(pool.map(inputs, to_list))
|
|
168
|
+
else:
|
|
169
|
+
found = [inputs(e) for e in to_list]
|
|
170
|
+
listed = {e.id: f for e, f in zip(to_list, found, strict=True)}
|
|
167
171
|
client = storage_client(report_storage)
|
|
168
172
|
|
|
169
173
|
def finish(e: EntityConfig) -> dict[str, Any]:
|
floe/run/entity.py
CHANGED
|
@@ -7,7 +7,7 @@ import time
|
|
|
7
7
|
from collections.abc import Callable, Generator, Iterable, Iterator
|
|
8
8
|
from contextlib import closing, suppress
|
|
9
9
|
from dataclasses import dataclass, field, replace
|
|
10
|
-
from datetime import datetime
|
|
10
|
+
from datetime import date, datetime
|
|
11
11
|
from functools import partial
|
|
12
12
|
from itertools import chain
|
|
13
13
|
from pathlib import Path
|
|
@@ -93,6 +93,7 @@ from floe.storage.partitioning import (
|
|
|
93
93
|
advance,
|
|
94
94
|
in_load_order,
|
|
95
95
|
listing_start,
|
|
96
|
+
partition_date,
|
|
96
97
|
partition_of,
|
|
97
98
|
source_root,
|
|
98
99
|
window,
|
|
@@ -313,7 +314,11 @@ def _with_archived(e: EntityConfig, archive: tuple[str, StorageClient], source:
|
|
|
313
314
|
source()
|
|
314
315
|
) # nothing archived: the source's "no input files matched" stands
|
|
315
316
|
if p is not None: # D29: the archive keeps the partition sub-paths
|
|
316
|
-
|
|
317
|
+
# #939: D28 orders a day's batches by the source's modification time, which the copy's own does not keep
|
|
318
|
+
kept = [replace(f, source_mtime=client.source_mtime(f.source_uri) or f.source_mtime) for f in old.files] # fmt: skip
|
|
319
|
+
old = in_load_order(
|
|
320
|
+
p, e.name, f"{base}/{e.name}", replace(old, files=kept), archived=True
|
|
321
|
+
)
|
|
317
322
|
try:
|
|
318
323
|
new = source()
|
|
319
324
|
except NoInputsError: # no new source file: rebuild from the archive alone
|
|
@@ -402,7 +407,8 @@ def _run_file(plan: _Plan, file: InputFile, path: Path, spill: Path) -> tuple[di
|
|
|
402
407
|
def failed(err: FloeIoError, mismatch: MismatchOutcome | None) -> tuple[dict[str, Any], Iterable[pl.DataFrame]]: # fmt: skip
|
|
403
408
|
# Rust skips the raw copy under PII: the file cannot be masked
|
|
404
409
|
plan.tracker.restore(keys) # the unreadable file leaves no keys behind
|
|
405
|
-
|
|
410
|
+
# a file removed after listing has nothing to copy: the read error alone is reported
|
|
411
|
+
keep = entity.pii is None and entity.sink.rejected is not None and path.exists()
|
|
406
412
|
raw = _copy_raw(plan, path, severity, file.source_name) if keep else None
|
|
407
413
|
return _failed_file(entity, uri, len(declared) + len(derived), mismatch, err, raw), [] # fmt: skip
|
|
408
414
|
|
|
@@ -633,6 +639,7 @@ class Incremental:
|
|
|
633
639
|
# the file reports, set once the sink step is done (#915)
|
|
634
640
|
self.committed: list[dict[str, Any]] | None = None
|
|
635
641
|
self.advance: Callable[[EntityState, list[str]], None] | None = None # D30
|
|
642
|
+
self.dated: Callable[[str], date | None] | None = None # #949: an input's partition date # fmt: skip
|
|
636
643
|
self._stop = threading.Event()
|
|
637
644
|
self._thread: threading.Thread | None = None
|
|
638
645
|
|
|
@@ -643,9 +650,11 @@ class Incremental:
|
|
|
643
650
|
return inputs, []
|
|
644
651
|
self.uri, self.client = state_uri(e, self.config_dir, self.storages)
|
|
645
652
|
if self.full_refresh:
|
|
646
|
-
|
|
653
|
+
# #949: `expired` holds the claims it replaced, which `release` restores if the refresh wrote nothing
|
|
654
|
+
claimed, self.expired = claim_all(self.client, self.uri, e.name, self.run_id, inputs) # fmt: skip
|
|
655
|
+
self._hold(claimed)
|
|
647
656
|
return inputs, []
|
|
648
|
-
got = claim_inputs(self.client, self.uri, e.name, self.run_id, inputs, e.sink.write_mode.startswith("merge")) # fmt: skip
|
|
657
|
+
got = claim_inputs(self.client, self.uri, e.name, self.run_id, inputs, e.sink.write_mode.startswith("merge"), self.dated) # fmt: skip
|
|
649
658
|
reports = skipped_reports(e, got.already_processed, got.active_claims)
|
|
650
659
|
_emit_skipped(emit, e.name, reports)
|
|
651
660
|
self.expired = got.expired
|
|
@@ -715,10 +724,17 @@ class Incremental:
|
|
|
715
724
|
return claimed
|
|
716
725
|
|
|
717
726
|
def release(self) -> None:
|
|
718
|
-
"""The dead runs' claims this run took over go back: the next run checks them again (#545).
|
|
727
|
+
"""The dead runs' claims this run took over go back: the next run checks them again (#545).
|
|
728
|
+
|
|
729
|
+
#949: a full refresh puts back the claims it replaced only if it wrote nothing, as its overwrite drops their rows
|
|
730
|
+
and the retry guard would find their run's snapshot behind it.
|
|
731
|
+
"""
|
|
719
732
|
if claimed := self._end():
|
|
733
|
+
restore = (
|
|
734
|
+
{} if self.full_refresh and self.committed is not None else self.expired
|
|
735
|
+
)
|
|
720
736
|
# D30: an older day's released file stays listed for a retry
|
|
721
|
-
release(self.client, self.uri, self.run_id, claimed,
|
|
737
|
+
release(self.client, self.uri, self.run_id, claimed, restore, self.advance is not None and not self.full_refresh) # fmt: skip
|
|
722
738
|
|
|
723
739
|
def finish(self, files: list[dict[str, Any]]) -> None:
|
|
724
740
|
"""Promote unless the entity failed (Rust commits on success, warnings and rejected).
|
|
@@ -786,12 +802,18 @@ def list_source(entity: EntityConfig, config_dir: Path, storages: StoragesConfig
|
|
|
786
802
|
after = listing_start(p, start) if p and start else None
|
|
787
803
|
|
|
788
804
|
def source() -> ResolvedInputs:
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
805
|
+
try:
|
|
806
|
+
inputs = resolve_inputs(
|
|
807
|
+
config_dir, src.path if uri.startswith("local://") else uri, src.format, entity.name,
|
|
808
|
+
src.storage or "local", client, glob_override=src.options.glob, exclude=archive and archive[0],
|
|
809
|
+
recursive=bool(src.options.recursive) or p is not None, # D28: partitioning lists recursively
|
|
810
|
+
after=f"{root}/{after}" if after else "",
|
|
811
|
+
) # fmt: skip
|
|
812
|
+
except NoInputsError:
|
|
813
|
+
# #1139: an empty incremental source has nothing pending; a full refresh still fails (a broken extraction)
|
|
814
|
+
if entity.incremental_mode != "file" or full_refresh:
|
|
815
|
+
raise
|
|
816
|
+
return ResolvedInputs([], [], "directory")
|
|
795
817
|
if not p:
|
|
796
818
|
return inputs
|
|
797
819
|
inputs = in_load_order(p, entity.name, root, inputs, start=start)
|
|
@@ -921,6 +943,7 @@ def _run_entity(
|
|
|
921
943
|
if entity.source.partitioning and entity.incremental_mode == "file":
|
|
922
944
|
dated = entity.source.partitioning, entity.name, source_root(source_uri)
|
|
923
945
|
inc.advance = partial(advance, *dated)
|
|
946
|
+
inc.dated = partial(partition_date, *dated)
|
|
924
947
|
archive = plan.archive # a full refresh lists it too
|
|
925
948
|
plan.derived = _derived(entity, source_uri, archive and archive[0], inputs.files)
|
|
926
949
|
# D34 (#931): same store only (a bucket name repeats across endpoints); before the claim, so a refusal leaves nothing to release
|
floe/state.py
CHANGED
|
@@ -6,7 +6,7 @@ import random
|
|
|
6
6
|
import time
|
|
7
7
|
from collections.abc import Callable
|
|
8
8
|
from dataclasses import asdict, dataclass
|
|
9
|
-
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from datetime import UTC, date, datetime, timedelta
|
|
10
10
|
from typing import NamedTuple
|
|
11
11
|
|
|
12
12
|
from floe.errors import FloeConfigError
|
|
@@ -44,6 +44,8 @@ class EntityState:
|
|
|
44
44
|
files: dict[str, FileState]
|
|
45
45
|
claims: dict[str, Claim]
|
|
46
46
|
watermark: str | None = None # D30: the latest partition date ingested (ISO)
|
|
47
|
+
# #949: `files` holds no entry dated before it (ISO); only a full refresh lists before it
|
|
48
|
+
pruned_before: str | None = None
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
class Loaded(NamedTuple):
|
|
@@ -74,6 +76,8 @@ def dump_state(state: EntityState) -> bytes:
|
|
|
74
76
|
}
|
|
75
77
|
if state.watermark: # D30: additive, absent without partitioning
|
|
76
78
|
doc["watermark"] = state.watermark
|
|
79
|
+
if state.pruned_before: # #949: additive too
|
|
80
|
+
doc["pruned_before"] = state.pruned_before
|
|
77
81
|
return json.dumps(doc, indent=2, ensure_ascii=False).encode()
|
|
78
82
|
|
|
79
83
|
|
|
@@ -89,6 +93,7 @@ def parse_state(entity: str, body: bytes) -> EntityState:
|
|
|
89
93
|
if schema != SCHEMA_V1
|
|
90
94
|
else {},
|
|
91
95
|
doc.get("watermark"),
|
|
96
|
+
doc.get("pruned_before"),
|
|
92
97
|
)
|
|
93
98
|
except (ValueError, KeyError, TypeError, AttributeError) as e:
|
|
94
99
|
raise FloeConfigError(f"entity.name={entity} invalid state file: {e}") from e
|
|
@@ -96,6 +101,13 @@ def parse_state(entity: str, body: bytes) -> EntityState:
|
|
|
96
101
|
raise FloeConfigError(
|
|
97
102
|
f"entity.name={entity} state schema mismatch: expected {SCHEMA_V1} or {SCHEMA_V2}, got {schema}"
|
|
98
103
|
)
|
|
104
|
+
if state.pruned_before is not None: # #949: compared as an ISO date string
|
|
105
|
+
try:
|
|
106
|
+
date.fromisoformat(state.pruned_before)
|
|
107
|
+
except (ValueError, TypeError) as e:
|
|
108
|
+
raise FloeConfigError(
|
|
109
|
+
f"entity.name={entity} invalid state file: pruned_before: {e}"
|
|
110
|
+
) from e
|
|
99
111
|
if state.entity != entity:
|
|
100
112
|
raise FloeConfigError(
|
|
101
113
|
f"entity.name={entity} state entity mismatch: expected {entity}, got {state.entity}"
|
|
@@ -138,8 +150,13 @@ def claim_inputs(
|
|
|
138
150
|
run_id: str,
|
|
139
151
|
inputs: list[InputFile],
|
|
140
152
|
barrier: bool = False,
|
|
153
|
+
dated: Callable[[str], date | None] | None = None,
|
|
141
154
|
) -> ClaimOutcome:
|
|
142
|
-
"""`barrier` (merge modes, #545 S7): while another run's claim is live, every input waits as `active_claim`.
|
|
155
|
+
"""`barrier` (merge modes, #545 S7): while another run's claim is live, every input waits as `active_claim`.
|
|
156
|
+
|
|
157
|
+
`dated` (#949): an input's partition date; an unclaimed one dated before `pruned_before` is dropped, as a later
|
|
158
|
+
window would not list it (a run that listed before another pruned would re-ingest it).
|
|
159
|
+
"""
|
|
143
160
|
if not inputs:
|
|
144
161
|
return ClaimOutcome([], None, [], [], {})
|
|
145
162
|
for attempt in range(CAS_RETRIES):
|
|
@@ -157,6 +174,10 @@ def claim_inputs(
|
|
|
157
174
|
processed: list[tuple[InputFile, FileState]] = []
|
|
158
175
|
acquired_at, expires_at = _now(), _now(CLAIM_TTL_SECONDS)
|
|
159
176
|
for f in inputs:
|
|
177
|
+
day = dated(f.source_uri) if dated and state.pruned_before else None
|
|
178
|
+
# a claimed one is a retry: the window is held back to it
|
|
179
|
+
if day and day.isoformat() < (state.pruned_before or "") and f.source_uri not in expired and f.source_uri not in state.claims: # fmt: skip
|
|
180
|
+
continue
|
|
160
181
|
if f.source_uri in state.files:
|
|
161
182
|
processed.append((f, state.files[f.source_uri]))
|
|
162
183
|
elif blocked or f.source_uri in state.claims:
|
|
@@ -186,8 +207,11 @@ def claim_inputs(
|
|
|
186
207
|
|
|
187
208
|
def claim_all(
|
|
188
209
|
client: StorageClient, uri: str, entity: str, run_id: str, inputs: list[InputFile]
|
|
189
|
-
) -> Loaded:
|
|
190
|
-
"""Full refresh: claim every input, dropping other claims (Rust `claim_all_entity_inputs`).
|
|
210
|
+
) -> tuple[Loaded, dict[str, Claim]]:
|
|
211
|
+
"""Full refresh: claim every input, dropping other claims (Rust `claim_all_entity_inputs`).
|
|
212
|
+
|
|
213
|
+
Also the claims it replaced, by URI, for `release` to restore if the refresh fails (#949).
|
|
214
|
+
"""
|
|
191
215
|
for attempt in range(CAS_RETRIES):
|
|
192
216
|
if attempt:
|
|
193
217
|
_backoff(attempt - 1)
|
|
@@ -197,11 +221,13 @@ def claim_all(
|
|
|
197
221
|
f.source_uri: Claim(run_id, acquired_at, expires_at, f.source_size, f.source_mtime)
|
|
198
222
|
for f in inputs
|
|
199
223
|
} # fmt: skip
|
|
200
|
-
# D30: no watermark until the refresh promotes, so a failed one
|
|
201
|
-
|
|
224
|
+
# D30: no watermark until the refresh promotes; #949: `pruned_before` stays, so after a failed one the next run
|
|
225
|
+
# lists from it, never the pruned days
|
|
226
|
+
fresh = Loaded(EntityState(entity, acquired_at, loaded.state.files, claims, None, loaded.state.pruned_before), loaded.version) # fmt: skip
|
|
202
227
|
version = _persist(client, uri, fresh)
|
|
203
228
|
if version is not None:
|
|
204
|
-
|
|
229
|
+
replaced = {u: c for u, c in loaded.state.claims.items() if u in claims}
|
|
230
|
+
return Loaded(fresh.state, version), replaced
|
|
205
231
|
raise FloeConfigError(
|
|
206
232
|
f"entity.name={entity} full-refresh state write conflicted after {CAS_RETRIES} retries"
|
|
207
233
|
)
|
|
@@ -225,7 +251,7 @@ def _mutate(
|
|
|
225
251
|
)
|
|
226
252
|
mutate(loaded.state, ours)
|
|
227
253
|
state = loaded.state
|
|
228
|
-
if state.files or state.claims or state.watermark:
|
|
254
|
+
if state.files or state.claims or state.watermark or state.pruned_before:
|
|
229
255
|
done = _persist(client, uri, loaded) is not None
|
|
230
256
|
else: # Rust deletes an emptied state
|
|
231
257
|
done = client.delete_conditional(uri, loaded.version)
|
floe/storage/archive.py
CHANGED
|
@@ -21,7 +21,7 @@ def archive_input_file(
|
|
|
21
21
|
f"{entity_name}/{subdir}" if subdir else entity_name, input_file.source_name, run_id, input_file.source_uri
|
|
22
22
|
) # fmt: skip
|
|
23
23
|
dest_uri = join_relative(relative)
|
|
24
|
-
client.copy(input_file.source_uri, dest_uri)
|
|
24
|
+
client.copy(input_file.source_uri, dest_uri, input_file.source_mtime)
|
|
25
25
|
try:
|
|
26
26
|
client.delete(input_file.source_uri)
|
|
27
27
|
except FloeError as e:
|
floe/storage/local.py
CHANGED
|
@@ -96,7 +96,8 @@ class LocalClient:
|
|
|
96
96
|
key=lambda r: r.uri,
|
|
97
97
|
)
|
|
98
98
|
|
|
99
|
-
def copy(self, src_uri: str, dst_uri: str) -> None:
|
|
99
|
+
def copy(self, src_uri: str, dst_uri: str, source_mtime: str | None = None) -> None:
|
|
100
|
+
"""`source_mtime` is not kept: a local copy's nanosecond mtime already follows the archive (load) order (#939)."""
|
|
100
101
|
src, dst = _path(src_uri), _path(dst_uri)
|
|
101
102
|
try:
|
|
102
103
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -109,6 +110,9 @@ class LocalClient:
|
|
|
109
110
|
except OSError as e:
|
|
110
111
|
raise FloeStorageError(f"local copy failed from {src} to {dst}: {e}") from e
|
|
111
112
|
|
|
113
|
+
def source_mtime(self, uri: str) -> str | None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
112
116
|
def write(self, uri: str, body: bytes) -> None:
|
|
113
117
|
path = _path(uri)
|
|
114
118
|
try:
|
floe/storage/partitioning.py
CHANGED
|
@@ -76,7 +76,7 @@ def _day(when: date | datetime) -> date:
|
|
|
76
76
|
return when.date() if isinstance(when, datetime) else when
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def
|
|
79
|
+
def partition_date(p: SourcePartitioning, entity: str, root: str, uri: str) -> date | None: # fmt: skip
|
|
80
80
|
"""The partition date of a state entry; None for one outside the pattern (another source path)."""
|
|
81
81
|
try:
|
|
82
82
|
return _day(partition_of(p, entity, root, uri).when)
|
|
@@ -89,10 +89,12 @@ def window(p: SourcePartitioning, entity: str, root: str, state: EntityState | N
|
|
|
89
89
|
|
|
90
90
|
An outstanding claim holds it back to the claim's date, so a crashed run's files stay listed until it is retried.
|
|
91
91
|
"""
|
|
92
|
-
if state is None or state.watermark
|
|
92
|
+
if state is None or not (state.watermark or state.pruned_before):
|
|
93
93
|
return None
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
# #949: never before `pruned_before`, whatever `lookback_days` became
|
|
95
|
+
floor = date.fromisoformat(state.pruned_before or date.min.isoformat())
|
|
96
|
+
start = max(floor, date.fromisoformat(state.watermark) - timedelta(days=p.lookback_days)) if state.watermark else floor # fmt: skip
|
|
97
|
+
return min([start, *(d for u in state.claims if (d := partition_date(p, entity, root, u)))]) # fmt: skip
|
|
96
98
|
|
|
97
99
|
|
|
98
100
|
def listing_start(p: SourcePartitioning, start: date) -> str | None:
|
|
@@ -107,10 +109,18 @@ def listing_start(p: SourcePartitioning, start: date) -> str | None:
|
|
|
107
109
|
|
|
108
110
|
|
|
109
111
|
def advance(p: SourcePartitioning, entity: str, root: str, state: EntityState, promoted: list[str]) -> None: # fmt: skip
|
|
110
|
-
"""D30, on promote: the watermark moves to the latest date promoted, never back
|
|
112
|
+
"""D30, on promote: the watermark moves to the latest date promoted, never back.
|
|
111
113
|
|
|
112
114
|
Without one (a first run, or after a failed full refresh), every file in the state counts.
|
|
115
|
+
#949: `pruned_before` moves to the window start, never back nor past a claim's date, and `files` drops the entries
|
|
116
|
+
dated before it, in the same state write.
|
|
113
117
|
"""
|
|
114
118
|
known = promoted if state.watermark else [*promoted, *state.files]
|
|
115
|
-
days = [d.isoformat() for u in known if (d :=
|
|
119
|
+
days = [d.isoformat() for u in known if (d := partition_date(p, entity, root, u))]
|
|
116
120
|
state.watermark = max([state.watermark or "", *days]) or None
|
|
121
|
+
if state.watermark is None:
|
|
122
|
+
return
|
|
123
|
+
start = date.fromisoformat(state.watermark) - timedelta(days=p.lookback_days)
|
|
124
|
+
held = [d for u in state.claims if (d := partition_date(p, entity, root, u))]
|
|
125
|
+
state.pruned_before = max(state.pruned_before or "", min([start, *held]).isoformat()) # fmt: skip
|
|
126
|
+
state.files = {u: f for u, f in state.files.items() if (d := partition_date(p, entity, root, u)) is None or d.isoformat() >= state.pruned_before} # fmt: skip
|
floe/storage/s3.py
CHANGED
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import shutil
|
|
5
5
|
from datetime import UTC, datetime
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
import boto3
|
|
9
10
|
from botocore.config import Config
|
|
@@ -15,6 +16,17 @@ from floe.storage.types import ObjectRef, StoredObject
|
|
|
15
16
|
|
|
16
17
|
_CONFLICT = ("PreconditionFailed", "ConditionalRequestConflict", "412", "409")
|
|
17
18
|
_NOT_FOUND = ("NoSuchKey", "NotFound", "404")
|
|
19
|
+
# #939: an archive copy's `LastModified` is its copy time, to the second; this keeps the source's, for D28 replay order
|
|
20
|
+
_SOURCE_MTIME = "floe-source-mtime"
|
|
21
|
+
# the headers a `MetadataDirective=REPLACE` copy would otherwise drop
|
|
22
|
+
_KEPT = (
|
|
23
|
+
"ContentType",
|
|
24
|
+
"ContentEncoding",
|
|
25
|
+
"ContentDisposition",
|
|
26
|
+
"ContentLanguage",
|
|
27
|
+
"CacheControl",
|
|
28
|
+
"Expires",
|
|
29
|
+
)
|
|
18
30
|
|
|
19
31
|
|
|
20
32
|
def resolve_path_style_access(configured: bool | None) -> bool:
|
|
@@ -117,15 +129,33 @@ class S3Client:
|
|
|
117
129
|
except (ClientError, BotoCoreError) as e:
|
|
118
130
|
raise FloeStorageError(f"s3 put object failed: {e}") from e
|
|
119
131
|
|
|
120
|
-
def copy(self, src_uri: str, dst_uri: str) -> None:
|
|
132
|
+
def copy(self, src_uri: str, dst_uri: str, source_mtime: str | None = None) -> None:
|
|
133
|
+
"""`source_mtime` goes to the copy's `x-amz-meta-floe-source-mtime`, with the source's metadata and headers."""
|
|
121
134
|
(sb, sk), (db, dk) = parse_s3_uri(src_uri), parse_s3_uri(dst_uri)
|
|
122
135
|
try:
|
|
136
|
+
extra: dict[str, Any] = {}
|
|
137
|
+
if source_mtime:
|
|
138
|
+
head = self._client.head_object(Bucket=sb, Key=sk)
|
|
139
|
+
meta = {**head.get("Metadata", {}), _SOURCE_MTIME: source_mtime}
|
|
140
|
+
extra = {k: head[k] for k in _KEPT if k in head} | {"Metadata": meta, "MetadataDirective": "REPLACE"} # fmt: skip
|
|
123
141
|
self._client.copy_object(
|
|
124
|
-
Bucket=db, Key=dk, CopySource={"Bucket": sb, "Key": sk}
|
|
142
|
+
Bucket=db, Key=dk, CopySource={"Bucket": sb, "Key": sk}, **extra
|
|
125
143
|
)
|
|
126
144
|
except (ClientError, BotoCoreError) as e:
|
|
127
145
|
raise FloeStorageError(f"s3 copy object failed: {e}") from e
|
|
128
146
|
|
|
147
|
+
def source_mtime(self, uri: str) -> str | None:
|
|
148
|
+
"""#939: the source modification time an archive copy keeps; None on a copy made before it was kept."""
|
|
149
|
+
bucket, key = parse_s3_uri(uri)
|
|
150
|
+
try:
|
|
151
|
+
meta = self._client.head_object(Bucket=bucket, Key=key).get("Metadata", {})
|
|
152
|
+
# a Go server (MinIO, SeaweedFS) may send the header canonicalised: `X-Amz-Meta-Floe-Source-Mtime`
|
|
153
|
+
return next(
|
|
154
|
+
(v for k, v in meta.items() if k.lower() == _SOURCE_MTIME), None
|
|
155
|
+
)
|
|
156
|
+
except (ClientError, BotoCoreError) as e:
|
|
157
|
+
raise FloeStorageError(f"s3 head object failed for {uri}: {e}") from e
|
|
158
|
+
|
|
129
159
|
def delete(self, uri: str) -> None:
|
|
130
160
|
bucket, key = parse_s3_uri(uri)
|
|
131
161
|
try:
|
floe/storage/types.py
CHANGED
|
@@ -20,7 +20,10 @@ class StorageClient(Protocol):
|
|
|
20
20
|
def resolve_uri(self, path: str) -> str: ...
|
|
21
21
|
def list(self, prefix: str, start_after: str = "") -> list[ObjectRef]: ...
|
|
22
22
|
def write(self, uri: str, body: bytes) -> None: ...
|
|
23
|
-
def copy(
|
|
23
|
+
def copy(
|
|
24
|
+
self, src_uri: str, dst_uri: str, source_mtime: str | None = None
|
|
25
|
+
) -> None: ...
|
|
26
|
+
def source_mtime(self, uri: str) -> str | None: ...
|
|
24
27
|
def delete(self, uri: str) -> None: ...
|
|
25
28
|
def read(self, uri: str) -> StoredObject | None: ...
|
|
26
29
|
def write_conditional(
|
|
@@ -6,33 +6,33 @@ floe/add_entity.py,sha256=42b7l8o55dzgaR_DDgpW7YWdnU2weYMf4U_rf7qCY6M,21986
|
|
|
6
6
|
floe/errors.py,sha256=hLSkgPQATBnjY1CAQqX4aB0oI0-WKoHW09bDXAgzC7k,186
|
|
7
7
|
floe/glob.py,sha256=A0HdePLXLGl1T6heJbgdrNHr8pZfdidG7pQ1QsXWOl8,10579
|
|
8
8
|
floe/maintain.py,sha256=CGSoZhO7p0u14o10FCKCO4FprzpVO548A71eq5FowF8,7343
|
|
9
|
-
floe/manifest.py,sha256=
|
|
9
|
+
floe/manifest.py,sha256=TDjSOrDXoH59lcYQ_FGIGCEg2JZ0EKGMw4cVtd1kzCA,20312
|
|
10
10
|
floe/manifest_run.py,sha256=8rkHX2RuxHyp02NI2NLBsU6xq6m5yebR0eoYETV_7Tw,12591
|
|
11
11
|
floe/pii.py,sha256=uJjU-C4c7h45E7rEL9K3Zp1LTTLh-FNKfUV5av2QMO4,4351
|
|
12
12
|
floe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
-
floe/state.py,sha256=
|
|
13
|
+
floe/state.py,sha256=H7BRVmpw2zfwPR9FiLqbHHV53hoxHNvXGTbT9jHonRU,12635
|
|
14
14
|
floe/checks/cast.py,sha256=lM40xtKEggWI8FW3jijM3T2JFQtaXs8FhsFXo3vy5oM,6788
|
|
15
15
|
floe/checks/mismatch.py,sha256=RNqRL1nihH6Y83RI-dxPXf0WTWZccXq6mnR4m6V0IIo,5827
|
|
16
16
|
floe/checks/normalize.py,sha256=9fdRYFjWGXBAEw1Po71N5tsUWG8eekmSWIQktPduOig,4431
|
|
17
17
|
floe/checks/rows.py,sha256=-yPieSus0-7ZewLv2vR8waHqQ9DGMbVvmJh1_hK4eDs,4433
|
|
18
|
-
floe/checks/unique.py,sha256=
|
|
18
|
+
floe/checks/unique.py,sha256=ZVckMOoGuKIzq3M84TGeZ1ks0nab9YTaI54w5Q_rjZc,10864
|
|
19
19
|
floe/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
20
|
floe/config/entity.py,sha256=CLXLXzkUbQhxenD_LwjBt0eoPreaYaxiBVEsdmnFIco,15883
|
|
21
21
|
floe/config/include.py,sha256=XKYhp2QK_VUmAODAXJ35Wl4FCpQEMQm24m-WKlpI_og,5033
|
|
22
22
|
floe/config/parse.py,sha256=B3gOi80-M0y9Y4OyNjM4MYmM2mFHKGVpqipX1JqaLjM,5290
|
|
23
|
-
floe/config/profile.py,sha256=
|
|
23
|
+
floe/config/profile.py,sha256=d_Lw8SGdO1h4of7Cl0fraAfB3W5sMYZvECguxgsdIGA,17253
|
|
24
24
|
floe/config/project.py,sha256=hUsnjbNAEiiy_iVJYA1IDquZ4OY4OF7JpNaEVJ_ocCg,14221
|
|
25
25
|
floe/config/storage.py,sha256=BK3P65ukFqNgmbjCcuYmXR04OScf1yD8f19vGJq-aDI,6234
|
|
26
26
|
floe/config/types.py,sha256=4eveoOlyGmNxyZpI1PSjli9lnXLPc1-jYj4fL8qHN_8,5038
|
|
27
27
|
floe/config/validate.py,sha256=7tyofwoTBL-BBCNxnmkXeSOybwmMNngeEcj8zviRyLs,36176
|
|
28
28
|
floe/config/vars.py,sha256=7Bq0IU0KrFeRlyA8W9-F0gDSDKfXCfFpBrX08H8Flvw,4577
|
|
29
29
|
floe/config/yaml.py,sha256=HeFGhKFxYkiqQOwljYOgUD5naJyxL_ZIX6hUIS36tzU,10071
|
|
30
|
-
floe/iceberg/catalog.py,sha256=
|
|
31
|
-
floe/iceberg/compact.py,sha256=
|
|
30
|
+
floe/iceberg/catalog.py,sha256=qEhMvH4NFBd4GxqBEg4jqgTNxtqWkL_RRzIlgXr2GmA,16722
|
|
31
|
+
floe/iceberg/compact.py,sha256=te8QLzg-iU_Y7Z9z0BSqMf_vjuV6CzQGV_3OeutSaZg,7237
|
|
32
32
|
floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
|
|
33
|
-
floe/iceberg/sink.py,sha256=
|
|
33
|
+
floe/iceberg/sink.py,sha256=vdkgLjqvWH7opOrfc-147xCMQhLnxRjUzLjHLchHlAM,12043
|
|
34
34
|
floe/iceberg/unique.py,sha256=OmiPQthhB5jGTaXsCu9ZztNFEFUf6eT0MLU2Gnh-C2A,1095
|
|
35
|
-
floe/iceberg/write.py,sha256=
|
|
35
|
+
floe/iceberg/write.py,sha256=YNE8IwLV9jK3vksO0XCj36HKHqW6ZEHCu4173Xpi6hg,12854
|
|
36
36
|
floe/lineage/events.py,sha256=4txd8bGUwKBZd-8Aw_ebTurhlxAYk8SVMgH8vSjC3bs,6682
|
|
37
37
|
floe/lineage/observer.py,sha256=IFHjzsT65L-NkcQ8Noa_8CDVpLu4iGadRB2-K0Lj8t8,10216
|
|
38
38
|
floe/read/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -45,24 +45,24 @@ floe/read/xlsx.py,sha256=dengSY8HuNh9ahyR_LR8beqXTZQubqQ1Jx8qEak5jrY,3009
|
|
|
45
45
|
floe/read/xml.py,sha256=7m6DtoU8iExuLROavyojysqOUE_Bd_N0uwFaLAzjI_Y,8004
|
|
46
46
|
floe/report/entity.py,sha256=FRwLtXrlJXDNXrOKscitm-NCNJWQXd0vr_JIf_cUp20,9867
|
|
47
47
|
floe/report/summary.py,sha256=tb547VRkM5WGWHQ-4vZB2DUW4VTWgv9FUoym-NpuWE4,4452
|
|
48
|
-
floe/run/__init__.py,sha256=
|
|
49
|
-
floe/run/entity.py,sha256=
|
|
48
|
+
floe/run/__init__.py,sha256=yCLCoLbtA_Iuy7wabkFSImeuU8xrLp8Q0ZgYmw8WDRg,9229
|
|
49
|
+
floe/run/entity.py,sha256=MlNTdUM8dcZxubB_0FkjfSGbSz2TMcaBBm9Q9V4P7Rk,51072
|
|
50
50
|
floe/run/events.py,sha256=OE7zYkt0Dz2xrvgNavT1nHfZJ-97IIJ8LpHcameUJZU,2859
|
|
51
51
|
floe/run/parallel.py,sha256=lKBW9NOpOu6KFOcEo-RQ56ukEOKHXPPoLI3o6gmlY_g,3435
|
|
52
52
|
floe/storage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
|
-
floe/storage/archive.py,sha256=
|
|
53
|
+
floe/storage/archive.py,sha256=JQKizd5PcWWhOFr4nxnkp2OT8Tl1h-Bu28EXWWj-gMc,1126
|
|
54
54
|
floe/storage/inputs.py,sha256=nmgEo2ub3hPUghLUXHU1wnKzpL4aYZbHb6S0XPYvMew,10410
|
|
55
|
-
floe/storage/local.py,sha256=
|
|
56
|
-
floe/storage/partitioning.py,sha256=
|
|
55
|
+
floe/storage/local.py,sha256=uz7LrtjItFKVtnzna3b06hv5Cv3ov1l79Y2NmxUJ-pg,6617
|
|
56
|
+
floe/storage/partitioning.py,sha256=kheJLDtRyiizgsCiv_8DChAv9Rc5dOxF0q8_yVZEjFE,7057
|
|
57
57
|
floe/storage/paths.py,sha256=Z0fry3Gp0Tu2DpUeKqNXdqYP6n_KcKzK2llGx0TGHB8,2343
|
|
58
58
|
floe/storage/prefetch.py,sha256=b-_be13xkJIw3LVndOb6SouTd1Pf8uZwG6Wy0Nk1pr8,1801
|
|
59
59
|
floe/storage/resolve.py,sha256=0hGSdU_p10j9vzET25Kcnd-QF6H7uN-hzpo_SC-DV_c,3154
|
|
60
|
-
floe/storage/s3.py,sha256=
|
|
61
|
-
floe/storage/types.py,sha256=
|
|
60
|
+
floe/storage/s3.py,sha256=jG9GWCSSl1NtFus8C_vCe1wtHuKKZs1Jwz0i3jFFT1M,9074
|
|
61
|
+
floe/storage/types.py,sha256=sb-XQr0f7f4JgmfuAFRjHaaGmn63T7v09AvvScXGOTg,1096
|
|
62
62
|
floe/write/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
63
63
|
floe/write/outputs.py,sha256=cXxGUIJnZ38XPaqEI6a83GFUsapBRdY52XQm3GlUPcI,6937
|
|
64
|
-
floe_python-0.
|
|
65
|
-
floe_python-0.
|
|
66
|
-
floe_python-0.
|
|
67
|
-
floe_python-0.
|
|
68
|
-
floe_python-0.
|
|
64
|
+
floe_python-0.9.0.dist-info/METADATA,sha256=XrdM3vzJi9FKxGWCeybRfWpBKMYvvnZAMt-zD2AZTKA,442
|
|
65
|
+
floe_python-0.9.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
66
|
+
floe_python-0.9.0.dist-info/entry_points.txt,sha256=DU75r-E3zcK0quvwg5iT5DeWbUJck_Tz80bEj1IIzHA,40
|
|
67
|
+
floe_python-0.9.0.dist-info/licenses/LICENSE,sha256=dUhuoK-TCRQMpuLEAdfme-qPSJI0TlcH9jlNxeg9_EQ,1056
|
|
68
|
+
floe_python-0.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|