floe-python 0.8.9__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/config/profile.py +29 -21
- floe/iceberg/compact.py +3 -1
- floe/manifest.py +4 -7
- floe/run/entity.py +34 -12
- 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.9.dist-info → floe_python-0.9.0.dist-info}/METADATA +1 -1
- {floe_python-0.8.9.dist-info → floe_python-0.9.0.dist-info}/RECORD +15 -15
- {floe_python-0.8.9.dist-info → floe_python-0.9.0.dist-info}/WHEEL +0 -0
- {floe_python-0.8.9.dist-info → floe_python-0.9.0.dist-info}/entry_points.txt +0 -0
- {floe_python-0.8.9.dist-info → floe_python-0.9.0.dist-info}/licenses/LICENSE +0 -0
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/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/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/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
|
|
@@ -634,6 +639,7 @@ class Incremental:
|
|
|
634
639
|
# the file reports, set once the sink step is done (#915)
|
|
635
640
|
self.committed: list[dict[str, Any]] | None = None
|
|
636
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
|
|
637
643
|
self._stop = threading.Event()
|
|
638
644
|
self._thread: threading.Thread | None = None
|
|
639
645
|
|
|
@@ -644,9 +650,11 @@ class Incremental:
|
|
|
644
650
|
return inputs, []
|
|
645
651
|
self.uri, self.client = state_uri(e, self.config_dir, self.storages)
|
|
646
652
|
if self.full_refresh:
|
|
647
|
-
|
|
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)
|
|
648
656
|
return inputs, []
|
|
649
|
-
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
|
|
650
658
|
reports = skipped_reports(e, got.already_processed, got.active_claims)
|
|
651
659
|
_emit_skipped(emit, e.name, reports)
|
|
652
660
|
self.expired = got.expired
|
|
@@ -716,10 +724,17 @@ class Incremental:
|
|
|
716
724
|
return claimed
|
|
717
725
|
|
|
718
726
|
def release(self) -> None:
|
|
719
|
-
"""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
|
+
"""
|
|
720
732
|
if claimed := self._end():
|
|
733
|
+
restore = (
|
|
734
|
+
{} if self.full_refresh and self.committed is not None else self.expired
|
|
735
|
+
)
|
|
721
736
|
# D30: an older day's released file stays listed for a retry
|
|
722
|
-
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
|
|
723
738
|
|
|
724
739
|
def finish(self, files: list[dict[str, Any]]) -> None:
|
|
725
740
|
"""Promote unless the entity failed (Rust commits on success, warnings and rejected).
|
|
@@ -787,12 +802,18 @@ def list_source(entity: EntityConfig, config_dir: Path, storages: StoragesConfig
|
|
|
787
802
|
after = listing_start(p, start) if p and start else None
|
|
788
803
|
|
|
789
804
|
def source() -> ResolvedInputs:
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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")
|
|
796
817
|
if not p:
|
|
797
818
|
return inputs
|
|
798
819
|
inputs = in_load_order(p, entity.name, root, inputs, start=start)
|
|
@@ -922,6 +943,7 @@ def _run_entity(
|
|
|
922
943
|
if entity.source.partitioning and entity.incremental_mode == "file":
|
|
923
944
|
dated = entity.source.partitioning, entity.name, source_root(source_uri)
|
|
924
945
|
inc.advance = partial(advance, *dated)
|
|
946
|
+
inc.dated = partial(partition_date, *dated)
|
|
925
947
|
archive = plan.archive # a full refresh lists it too
|
|
926
948
|
plan.derived = _derived(entity, source_uri, archive and archive[0], inputs.files)
|
|
927
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,11 +6,11 @@ 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
|
|
@@ -20,7 +20,7 @@ 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
|
|
@@ -28,7 +28,7 @@ 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
30
|
floe/iceberg/catalog.py,sha256=qEhMvH4NFBd4GxqBEg4jqgTNxtqWkL_RRzIlgXr2GmA,16722
|
|
31
|
-
floe/iceberg/compact.py,sha256=
|
|
31
|
+
floe/iceberg/compact.py,sha256=te8QLzg-iU_Y7Z9z0BSqMf_vjuV6CzQGV_3OeutSaZg,7237
|
|
32
32
|
floe/iceberg/merge.py,sha256=VE1Odf2B34Gxj7ZcuhbrfbgTGM9AdiDz6_2BbcQ-kSQ,12448
|
|
33
33
|
floe/iceberg/sink.py,sha256=vdkgLjqvWH7opOrfc-147xCMQhLnxRjUzLjHLchHlAM,12043
|
|
34
34
|
floe/iceberg/unique.py,sha256=OmiPQthhB5jGTaXsCu9ZztNFEFUf6eT0MLU2Gnh-C2A,1095
|
|
@@ -46,23 +46,23 @@ 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
48
|
floe/run/__init__.py,sha256=yCLCoLbtA_Iuy7wabkFSImeuU8xrLp8Q0ZgYmw8WDRg,9229
|
|
49
|
-
floe/run/entity.py,sha256=
|
|
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
|