floe-python 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- floe/__init__.py +327 -0
- floe/__main__.py +5 -0
- floe/_cli.py +302 -0
- floe/_cli_output.py +187 -0
- floe/add_entity.py +516 -0
- floe/checks/cast.py +160 -0
- floe/checks/mismatch.py +137 -0
- floe/checks/normalize.py +118 -0
- floe/checks/rows.py +116 -0
- floe/checks/unique.py +247 -0
- floe/config/__init__.py +0 -0
- floe/config/entity.py +479 -0
- floe/config/include.py +122 -0
- floe/config/parse.py +140 -0
- floe/config/profile.py +381 -0
- floe/config/project.py +404 -0
- floe/config/storage.py +179 -0
- floe/config/types.py +195 -0
- floe/config/validate.py +803 -0
- floe/config/vars.py +127 -0
- floe/config/yaml.py +263 -0
- floe/errors.py +13 -0
- floe/glob.py +300 -0
- floe/iceberg/catalog.py +345 -0
- floe/iceberg/compact.py +139 -0
- floe/iceberg/merge.py +297 -0
- floe/iceberg/sink.py +208 -0
- floe/iceberg/unique.py +61 -0
- floe/iceberg/write.py +270 -0
- floe/lineage/events.py +163 -0
- floe/lineage/observer.py +261 -0
- floe/maintain.py +134 -0
- floe/manifest.py +503 -0
- floe/manifest_run.py +299 -0
- floe/pii.py +134 -0
- floe/py.typed +0 -0
- floe/read/__init__.py +0 -0
- floe/read/avro.py +40 -0
- floe/read/csv.py +138 -0
- floe/read/json.py +236 -0
- floe/read/orc.py +42 -0
- floe/read/parquet.py +43 -0
- floe/read/xlsx.py +75 -0
- floe/read/xml.py +227 -0
- floe/report/entity.py +245 -0
- floe/report/summary.py +114 -0
- floe/run/__init__.py +175 -0
- floe/run/entity.py +963 -0
- floe/run/events.py +42 -0
- floe/state.py +311 -0
- floe/storage/__init__.py +0 -0
- floe/storage/archive.py +31 -0
- floe/storage/inputs.py +302 -0
- floe/storage/local.py +175 -0
- floe/storage/partitioning.py +116 -0
- floe/storage/paths.py +69 -0
- floe/storage/resolve.py +55 -0
- floe/storage/s3.py +182 -0
- floe/storage/types.py +33 -0
- floe/write/__init__.py +0 -0
- floe/write/outputs.py +121 -0
- floe_python-0.8.0.dist-info/METADATA +15 -0
- floe_python-0.8.0.dist-info/RECORD +66 -0
- floe_python-0.8.0.dist-info/WHEEL +4 -0
- floe_python-0.8.0.dist-info/entry_points.txt +2 -0
- floe_python-0.8.0.dist-info/licenses/LICENSE +21 -0
floe/lineage/observer.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""OpenLineage observer for `floe run` over plain HTTP (Rust: `lineage/mod.rs`). Lineage failures never fail the run."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from http.client import HTTPConnection, HTTPMessage, HTTPResponse, HTTPSConnection
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO, Any
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from floe.config.project import LineageConfig, RootConfig
|
|
20
|
+
from floe.config.types import EntityConfig
|
|
21
|
+
from floe.config.validate import validate_config
|
|
22
|
+
from floe.errors import FloeError
|
|
23
|
+
from floe.lineage.events import Datasets, EventBuilder, event_type, resolve_api_key
|
|
24
|
+
from floe.run import RunOutcome, run
|
|
25
|
+
|
|
26
|
+
RETRY_DELAYS_S = (0.0, 0.1, 0.5)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _origin(url: str) -> tuple[str, str | None, int | None]:
|
|
30
|
+
u = urlsplit(url)
|
|
31
|
+
return u.scheme, u.hostname, u.port or {"http": 80, "https": 443}.get(u.scheme)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Attempt:
|
|
35
|
+
"""One POST and its redirects. On the deadline the caller cancels it: the live socket is shut down and nothing is sent afterwards."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.lock = threading.Lock()
|
|
39
|
+
self.cancelled = False
|
|
40
|
+
self.sock: socket.socket | None = None
|
|
41
|
+
|
|
42
|
+
def hooked(self, base: type[HTTPConnection]) -> type[HTTPConnection]:
|
|
43
|
+
attempt = self
|
|
44
|
+
|
|
45
|
+
class Conn(base):
|
|
46
|
+
def connect(self) -> None:
|
|
47
|
+
super().connect()
|
|
48
|
+
with attempt.lock:
|
|
49
|
+
if attempt.cancelled:
|
|
50
|
+
self.close()
|
|
51
|
+
raise OSError("lineage attempt cancelled")
|
|
52
|
+
attempt.sock = self.sock
|
|
53
|
+
|
|
54
|
+
return Conn
|
|
55
|
+
|
|
56
|
+
def cancel(self) -> None:
|
|
57
|
+
with self.lock:
|
|
58
|
+
self.cancelled = True
|
|
59
|
+
if self.sock:
|
|
60
|
+
with suppress(OSError):
|
|
61
|
+
self.sock.shutdown(socket.SHUT_RDWR)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Handler(urllib.request.HTTPHandler, urllib.request.HTTPSHandler):
|
|
65
|
+
"""urllib's HTTP(S) handlers, but every connection reports its socket to the attempt."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, attempt: _Attempt) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self.attempt = attempt
|
|
70
|
+
|
|
71
|
+
def http_open(self, req: urllib.request.Request) -> HTTPResponse:
|
|
72
|
+
return self.do_open(self.attempt.hooked(HTTPConnection), req)
|
|
73
|
+
|
|
74
|
+
def https_open(self, req: urllib.request.Request) -> HTTPResponse:
|
|
75
|
+
return self.do_open(self.attempt.hooked(HTTPSConnection), req)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Redirect(urllib.request.HTTPRedirectHandler):
|
|
79
|
+
"""Redirects as reqwest follows them: 307/308 keep the POST; `Authorization` never crosses origins."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, attempt: _Attempt) -> None:
|
|
82
|
+
self.attempt = attempt
|
|
83
|
+
|
|
84
|
+
def redirect_request(
|
|
85
|
+
self,
|
|
86
|
+
req: urllib.request.Request,
|
|
87
|
+
fp: IO[bytes],
|
|
88
|
+
code: int,
|
|
89
|
+
msg: str,
|
|
90
|
+
headers: HTTPMessage,
|
|
91
|
+
newurl: str,
|
|
92
|
+
) -> urllib.request.Request | None:
|
|
93
|
+
if self.attempt.cancelled:
|
|
94
|
+
return None
|
|
95
|
+
if code in (307, 308) and req.get_method() == "POST":
|
|
96
|
+
new = urllib.request.Request(
|
|
97
|
+
newurl, req.data, dict(req.headers), method="POST"
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
101
|
+
if new and _origin(newurl) != _origin(req.full_url):
|
|
102
|
+
new.remove_header("Authorization")
|
|
103
|
+
return new
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _warn(message: str) -> None:
|
|
107
|
+
print(f"warn: lineage: {message}", file=sys.stderr)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class OpenLineageObserver:
|
|
111
|
+
"""A run observer (`floe.run.events.Observer`) posting OpenLineage `RunEvent`s."""
|
|
112
|
+
|
|
113
|
+
# ponytail: unsynchronised breaker counters; add a lock when entities run in parallel.
|
|
114
|
+
def __init__(
|
|
115
|
+
self, config: LineageConfig, entities: Sequence[EntityConfig], config_path: str
|
|
116
|
+
) -> None:
|
|
117
|
+
self.config = config
|
|
118
|
+
self.events = EventBuilder(config, entities, config_path)
|
|
119
|
+
path = (config.endpoint or "").strip() or "api/v1/lineage"
|
|
120
|
+
self.url = f"{config.url.rstrip('/')}/{path.lstrip('/')}"
|
|
121
|
+
secret = config.api_key.get_secret_value() if config.api_key else None
|
|
122
|
+
self.api_key = resolve_api_key(secret, os.environ.get("OPENLINEAGE_API_KEY"))
|
|
123
|
+
self.disabled = bool(self.api_key and ("\r" in self.api_key or "\n" in self.api_key)) # fmt: skip
|
|
124
|
+
if self.disabled:
|
|
125
|
+
_warn("api_key contains a line break; events are not sent")
|
|
126
|
+
self.entity_run_ids: dict[str, str] = {}
|
|
127
|
+
self.run_uuid: str | None = None
|
|
128
|
+
self.consecutive_failures = 0
|
|
129
|
+
self.circuit_open = False
|
|
130
|
+
|
|
131
|
+
def __call__(self, event: dict[str, Any]) -> None:
|
|
132
|
+
kind, ts = event["event"], event["ts_ms"]
|
|
133
|
+
job = self.events.run_job_name
|
|
134
|
+
if kind == "run_started":
|
|
135
|
+
self.consecutive_failures, self.circuit_open = 0, False
|
|
136
|
+
self.run_uuid = str(uuid.uuid4())
|
|
137
|
+
self._post("START", ts, self.run_uuid, job)
|
|
138
|
+
elif kind == "entity_started":
|
|
139
|
+
run_id = self.entity_run_ids[event["name"]] = str(uuid.uuid4())
|
|
140
|
+
self._post("START", ts, run_id, event["name"])
|
|
141
|
+
elif kind == "entity_finished":
|
|
142
|
+
name = event["name"]
|
|
143
|
+
run_id = self.entity_run_ids.get(name, f"{event['run_id']}.entity.{name}")
|
|
144
|
+
inputs, outputs = self.events.datasets(name, event)
|
|
145
|
+
self._post(event_type(event["status"]), ts, run_id, name, inputs, outputs)
|
|
146
|
+
elif kind == "run_finished":
|
|
147
|
+
run_id = self.run_uuid or str(uuid.uuid4())
|
|
148
|
+
self._post(event_type(event["status"]), ts, run_id, job)
|
|
149
|
+
|
|
150
|
+
def fail_run(self) -> None:
|
|
151
|
+
"""A run that raised: `START` if it never started, then `FAIL` (Rust: the CLI failure path)."""
|
|
152
|
+
ts = time.time_ns() // 1_000_000
|
|
153
|
+
if self.run_uuid is None:
|
|
154
|
+
self({"event": "run_started", "ts_ms": ts})
|
|
155
|
+
self({"event": "run_finished", "status": "failed", "ts_ms": ts})
|
|
156
|
+
|
|
157
|
+
def _post(
|
|
158
|
+
self,
|
|
159
|
+
event_type: str,
|
|
160
|
+
ts_ms: int,
|
|
161
|
+
run_id: str,
|
|
162
|
+
job: str,
|
|
163
|
+
inputs: Datasets | None = None,
|
|
164
|
+
outputs: Datasets | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
if self.circuit_open or self.disabled:
|
|
167
|
+
return
|
|
168
|
+
body = self.events.body(event_type, ts_ms, run_id, job, inputs, outputs)
|
|
169
|
+
data = json.dumps(body).encode()
|
|
170
|
+
for delay in RETRY_DELAYS_S:
|
|
171
|
+
time.sleep(delay)
|
|
172
|
+
retryable = self._attempt(data)
|
|
173
|
+
if retryable is None:
|
|
174
|
+
self.consecutive_failures = 0
|
|
175
|
+
return
|
|
176
|
+
if not retryable:
|
|
177
|
+
break
|
|
178
|
+
_warn(f"POST {self.url} failed — event dropped")
|
|
179
|
+
self.consecutive_failures += 1
|
|
180
|
+
failures = self.consecutive_failures
|
|
181
|
+
if (
|
|
182
|
+
failures
|
|
183
|
+
>= (3 if self.config.max_failures is None else self.config.max_failures)
|
|
184
|
+
and not self.circuit_open
|
|
185
|
+
):
|
|
186
|
+
self.circuit_open = True
|
|
187
|
+
_warn(f"disabled for this run after {failures} consecutive failures — check endpoint {self.url}") # fmt: skip
|
|
188
|
+
|
|
189
|
+
def _attempt(self, data: bytes) -> bool | None:
|
|
190
|
+
"""None on success, else whether the failure is retryable (transport, timeout, 5xx, 429).
|
|
191
|
+
|
|
192
|
+
The timeout bounds the whole request, as reqwest's does; urllib's alone bounds each socket read.
|
|
193
|
+
An expired attempt is cancelled, so it never sends later (a redirect, a stale event).
|
|
194
|
+
"""
|
|
195
|
+
timeout = 5 if self.config.timeout_secs is None else self.config.timeout_secs
|
|
196
|
+
attempt = _Attempt()
|
|
197
|
+
result: list[bool | None] = []
|
|
198
|
+
thread = threading.Thread(
|
|
199
|
+
target=lambda: result.append(self._send(data, timeout, attempt)),
|
|
200
|
+
daemon=True,
|
|
201
|
+
)
|
|
202
|
+
thread.start()
|
|
203
|
+
thread.join(timeout)
|
|
204
|
+
if thread.is_alive():
|
|
205
|
+
attempt.cancel()
|
|
206
|
+
return True # an expired attempt failed, even if its cut-off read looks like a 200 (#886)
|
|
207
|
+
return result[0] if result else True
|
|
208
|
+
|
|
209
|
+
def _send(self, data: bytes, timeout: float, attempt: _Attempt) -> bool | None:
|
|
210
|
+
headers = {"Content-Type": "application/json"}
|
|
211
|
+
if self.api_key:
|
|
212
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
213
|
+
request = urllib.request.Request(self.url, data, headers, method="POST")
|
|
214
|
+
opener = urllib.request.build_opener(_Handler(attempt), _Redirect(attempt))
|
|
215
|
+
try:
|
|
216
|
+
with opener.open(request, timeout=timeout):
|
|
217
|
+
return None
|
|
218
|
+
except urllib.error.HTTPError as e:
|
|
219
|
+
return e.code == 429 or e.code >= 500
|
|
220
|
+
except Exception: # noqa: BLE001 -- the text may carry the token; never let it reach a thread hook
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def run_with_lineage(
|
|
225
|
+
path: Path,
|
|
226
|
+
root: RootConfig,
|
|
227
|
+
observed: Sequence[EntityConfig],
|
|
228
|
+
job: str,
|
|
229
|
+
observer: Callable[[dict[str, Any]], None],
|
|
230
|
+
*,
|
|
231
|
+
validate: bool,
|
|
232
|
+
run_id: str | None,
|
|
233
|
+
entities: Sequence[str],
|
|
234
|
+
dry_run: bool,
|
|
235
|
+
full_refresh: bool,
|
|
236
|
+
config_uri: str | None = None,
|
|
237
|
+
) -> RunOutcome:
|
|
238
|
+
"""`run`, posting OpenLineage events when the config has a `lineage` block (Rust: the CLI run path)."""
|
|
239
|
+
lineage = OpenLineageObserver(root.lineage, observed, job) if root.lineage else None
|
|
240
|
+
|
|
241
|
+
def observe(event: dict[str, Any]) -> None:
|
|
242
|
+
try:
|
|
243
|
+
observer(event)
|
|
244
|
+
finally: # a failing user observer never drops a lineage event
|
|
245
|
+
if lineage:
|
|
246
|
+
lineage(event)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
if validate: # after the observer: lineage sees a config that fails validation
|
|
250
|
+
validate_config(root)
|
|
251
|
+
return run(
|
|
252
|
+
path, root, run_id=run_id, entities=entities, dry_run=dry_run,
|
|
253
|
+
full_refresh=full_refresh, observer=observe, config_uri=config_uri,
|
|
254
|
+
) # fmt: skip
|
|
255
|
+
except FloeError:
|
|
256
|
+
if lineage:
|
|
257
|
+
try:
|
|
258
|
+
lineage.fail_run()
|
|
259
|
+
except Exception as e: # noqa: BLE001 -- lineage must not mask the run error
|
|
260
|
+
_warn(str(e))
|
|
261
|
+
raise
|
floe/maintain.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""`floe maintain`: Iceberg table maintenance for the entities' accepted sinks (#618, Python only)."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
|
|
4
|
+
# ^ pyarrow ships no type stubs
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from pyiceberg.table import Table
|
|
14
|
+
|
|
15
|
+
from floe.config.project import RootConfig
|
|
16
|
+
from floe.config.storage import StorageDefinition
|
|
17
|
+
from floe.config.validate import validate_config
|
|
18
|
+
from floe.errors import FloeConfigError, FloeRunError
|
|
19
|
+
from floe.iceberg.catalog import open_duckdb, run_error
|
|
20
|
+
from floe.iceberg.compact import compact
|
|
21
|
+
from floe.iceberg.sink import existing_table, resolve_target
|
|
22
|
+
from floe.run import select_entities
|
|
23
|
+
from floe.storage.paths import normalize_local_path, parse_s3_uri
|
|
24
|
+
from floe.storage.s3 import S3Client
|
|
25
|
+
|
|
26
|
+
# the retention floor (#545 decision 4): the retry guard reads snapshot summaries this recent
|
|
27
|
+
FLOOR_DAYS, FLOOR_SNAPSHOTS = 7, 10
|
|
28
|
+
# an orphan younger than the delay may be a file an in-flight `floe run` has not committed yet
|
|
29
|
+
ORPHAN_DELAY_DAYS, ORPHAN_FLOOR_DAYS = 3, 1
|
|
30
|
+
DAY = 86_400
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def expire_snapshots(table: Table, older_than_days: int, retain_last: int, dry_run: bool) -> tuple[int, int]: # fmt: skip
|
|
34
|
+
"""Expire snapshots older than the window and outside the newest `retain_last`; returns (expired, kept).
|
|
35
|
+
|
|
36
|
+
Metadata only: no file is deleted. Branch and tag heads are never expired.
|
|
37
|
+
"""
|
|
38
|
+
cutoff = (time.time() - older_than_days * DAY) * 1000
|
|
39
|
+
newest = sorted(table.metadata.snapshots, key=lambda s: s.timestamp_ms, reverse=True) # fmt: skip
|
|
40
|
+
protected = {ref.snapshot_id for ref in table.metadata.refs.values()}
|
|
41
|
+
expired = [
|
|
42
|
+
s.snapshot_id for s in newest[retain_last:]
|
|
43
|
+
if s.timestamp_ms < cutoff and s.snapshot_id not in protected
|
|
44
|
+
] # fmt: skip
|
|
45
|
+
if expired and not dry_run:
|
|
46
|
+
with run_error("iceberg snapshot expiry failed"):
|
|
47
|
+
table.maintenance.expire_snapshots().by_ids(expired).commit()
|
|
48
|
+
return len(expired), len(newest) - len(expired)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _key(uri: str) -> str:
|
|
52
|
+
"""`s3://b/k` -> `b/k`; `file:///p` and `/p` -> `/p`: metadata and listings spell paths differently."""
|
|
53
|
+
parts = urlsplit(uri)
|
|
54
|
+
return parts.netloc + parts.path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _list(location: str, storage: StorageDefinition | None) -> list[tuple[str, float | None, int]]: # fmt: skip
|
|
58
|
+
"""(uri, mtime, size) of every object under the table location."""
|
|
59
|
+
if location.startswith("s3://"):
|
|
60
|
+
bucket, key = parse_s3_uri(location)
|
|
61
|
+
s3 = storage if storage and storage.type == "s3" else None
|
|
62
|
+
client = S3Client(bucket, s3 and s3.region, s3 and s3.endpoint, s3 and s3.path_style_access) # fmt: skip
|
|
63
|
+
return [
|
|
64
|
+
(r.uri, datetime.fromisoformat(r.last_modified).timestamp() if r.last_modified else None,r.size or 0)
|
|
65
|
+
for r in client.list(f"{key.strip('/')}/" if key.strip("/") else "") # a bucket root lists every key
|
|
66
|
+
] # fmt: skip
|
|
67
|
+
# ponytail: LocalClient.list is flat and has no mtime (a Rust port), so a local warehouse is walked here
|
|
68
|
+
files = (p for p in Path(_key(location)).rglob("*") if p.is_file())
|
|
69
|
+
return [(f"file://{p}", (st := p.stat()).st_mtime, st.st_size) for p in files]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _reachable(table: Table) -> set[str]:
|
|
73
|
+
"""Every file the table's retained metadata references."""
|
|
74
|
+
m, inspect = table.metadata, table.inspect
|
|
75
|
+
paths = [table.metadata_location, *(e.metadata_file for e in m.metadata_log), *(s.manifest_list for s in m.snapshots)] # fmt: skip
|
|
76
|
+
paths += [s.statistics_path for s in m.statistics] + [s.statistics_path for s in m.partition_statistics] # fmt: skip
|
|
77
|
+
with run_error("iceberg metadata scan failed"):
|
|
78
|
+
paths += inspect.all_manifests()["path"].to_pylist() + inspect.all_files()["file_path"].to_pylist() # fmt: skip
|
|
79
|
+
return {_key(p) for p in paths}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def remove_orphans(table: Table, storage: StorageDefinition | None, delay_days: int, dry_run: bool) -> list[tuple[str, int]]: # fmt: skip
|
|
83
|
+
"""Delete the objects under the table location that no retained metadata references and older than the delay."""
|
|
84
|
+
listed = _list(table.location(), storage)
|
|
85
|
+
if _key(table.metadata_location) not in {_key(uri) for uri, _, _ in listed}:
|
|
86
|
+
# a listing that misses the current metadata would make every file look orphaned
|
|
87
|
+
raise FloeRunError(f"orphan scan of {table.location()} did not list the current metadata {table.metadata_location}; nothing was deleted") # fmt: skip
|
|
88
|
+
keep, cutoff = _reachable(table), time.time() - delay_days * DAY
|
|
89
|
+
orphans = [(uri, size) for uri, mtime, size in listed if _key(uri) not in keep and mtime is not None and mtime < cutoff] # fmt: skip
|
|
90
|
+
if not dry_run:
|
|
91
|
+
with run_error("iceberg orphan file delete failed"):
|
|
92
|
+
for uri, _ in orphans:
|
|
93
|
+
table.io.delete(uri)
|
|
94
|
+
return orphans
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def maintain(path: Path, root: RootConfig, entities: Sequence[str], older_than_days: int, retain_last: int, orphan_delay_days: int, dry_run: bool, compact_tables: bool, experimental: bool) -> list[dict[str, Any]]: # fmt: skip
|
|
98
|
+
"""Compact (experimental, opt-in), expire snapshots, then remove orphan files, of every selected entity's Iceberg table.
|
|
99
|
+
|
|
100
|
+
A request below the retention floor or the orphan delay floor fails closed.
|
|
101
|
+
"""
|
|
102
|
+
if compact_tables and not experimental:
|
|
103
|
+
raise FloeConfigError(
|
|
104
|
+
"--compact is experimental: pass --experimental to run it"
|
|
105
|
+
)
|
|
106
|
+
if compact_tables and dry_run:
|
|
107
|
+
raise FloeConfigError("--compact has no dry run: drop --dry-run or --compact")
|
|
108
|
+
if older_than_days < FLOOR_DAYS or retain_last < FLOOR_SNAPSHOTS:
|
|
109
|
+
raise FloeConfigError(
|
|
110
|
+
f"snapshot expiry below the retention floor: --older-than-days must be at least {FLOOR_DAYS} "
|
|
111
|
+
f"and --retain-last at least {FLOOR_SNAPSHOTS} (got {older_than_days} and {retain_last})"
|
|
112
|
+
)
|
|
113
|
+
if orphan_delay_days < ORPHAN_FLOOR_DAYS:
|
|
114
|
+
raise FloeConfigError(f"orphan delay below the floor: --orphan-delay-days must be at least {ORPHAN_FLOOR_DAYS} (got {orphan_delay_days})") # fmt: skip
|
|
115
|
+
validate_config(root)
|
|
116
|
+
config_dir = normalize_local_path(path.resolve().parent)
|
|
117
|
+
results: list[dict[str, Any]] = []
|
|
118
|
+
for entity in select_entities(root, entities):
|
|
119
|
+
if entity.sink.accepted.format != "iceberg":
|
|
120
|
+
continue
|
|
121
|
+
target = resolve_target(root.storages, root.catalogs, config_dir, entity)
|
|
122
|
+
table = existing_table(target)
|
|
123
|
+
compacted = None
|
|
124
|
+
if table and compact_tables:
|
|
125
|
+
with open_duckdb(target.catalog, target.storage) as con:
|
|
126
|
+
compacted = compact(table, con)
|
|
127
|
+
expired, kept = expire_snapshots(table, older_than_days, retain_last, dry_run) if table else (0, 0) # fmt: skip
|
|
128
|
+
orphans = remove_orphans(table, target.storage, orphan_delay_days, dry_run) if table else [] # fmt: skip
|
|
129
|
+
results.append({
|
|
130
|
+
"entity": entity.name, "table": f"{target.namespace}.{target.table}", "compaction": compacted,
|
|
131
|
+
"expired_snapshots": expired, "kept_snapshots": kept,
|
|
132
|
+
"orphan_files": [uri for uri, _ in orphans], "orphan_bytes": sum(size for _, size in orphans),
|
|
133
|
+
}) # fmt: skip
|
|
134
|
+
return results
|