fleetpull 0.1.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.
- fleetpull/__init__.py +36 -0
- fleetpull/api/__init__.py +32 -0
- fleetpull/api/auth_ingress.py +216 -0
- fleetpull/api/catalog.py +139 -0
- fleetpull/api/fetch.py +229 -0
- fleetpull/api/identity.py +90 -0
- fleetpull/api/sync.py +735 -0
- fleetpull/cli.py +146 -0
- fleetpull/config/__init__.py +47 -0
- fleetpull/config/base.py +18 -0
- fleetpull/config/example.py +90 -0
- fleetpull/config/geotab.py +78 -0
- fleetpull/config/http.py +32 -0
- fleetpull/config/loading.py +195 -0
- fleetpull/config/logger.py +139 -0
- fleetpull/config/providers.py +520 -0
- fleetpull/config/rate_limit.py +50 -0
- fleetpull/config/resolution.py +156 -0
- fleetpull/config/retry.py +69 -0
- fleetpull/config/root.py +157 -0
- fleetpull/config/sections.py +146 -0
- fleetpull/endpoints/__init__.py +24 -0
- fleetpull/endpoints/geotab/__init__.py +12 -0
- fleetpull/endpoints/geotab/_requests.py +427 -0
- fleetpull/endpoints/geotab/annotation_logs.py +73 -0
- fleetpull/endpoints/geotab/audits.py +70 -0
- fleetpull/endpoints/geotab/devices.py +79 -0
- fleetpull/endpoints/geotab/driver_changes.py +73 -0
- fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
- fleetpull/endpoints/geotab/dvir_logs.py +76 -0
- fleetpull/endpoints/geotab/exception_events.py +119 -0
- fleetpull/endpoints/geotab/fault_data.py +72 -0
- fleetpull/endpoints/geotab/fill_ups.py +79 -0
- fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
- fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
- fleetpull/endpoints/geotab/log_records.py +74 -0
- fleetpull/endpoints/geotab/media_files.py +74 -0
- fleetpull/endpoints/geotab/shipment_logs.py +71 -0
- fleetpull/endpoints/geotab/status_data.py +71 -0
- fleetpull/endpoints/geotab/text_messages.py +77 -0
- fleetpull/endpoints/geotab/trips.py +102 -0
- fleetpull/endpoints/geotab/users.py +81 -0
- fleetpull/endpoints/motive/__init__.py +12 -0
- fleetpull/endpoints/motive/_spec_builders.py +94 -0
- fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
- fleetpull/endpoints/motive/driving_periods.py +85 -0
- fleetpull/endpoints/motive/groups.py +61 -0
- fleetpull/endpoints/motive/idle_events.py +94 -0
- fleetpull/endpoints/motive/users.py +64 -0
- fleetpull/endpoints/motive/vehicle_locations.py +170 -0
- fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
- fleetpull/endpoints/motive/vehicles.py +106 -0
- fleetpull/endpoints/registry.py +280 -0
- fleetpull/endpoints/samsara/__init__.py +12 -0
- fleetpull/endpoints/samsara/_spec_builders.py +202 -0
- fleetpull/endpoints/samsara/addresses.py +77 -0
- fleetpull/endpoints/samsara/asset_locations.py +217 -0
- fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
- fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
- fleetpull/endpoints/samsara/drivers.py +169 -0
- fleetpull/endpoints/samsara/engine_states.py +114 -0
- fleetpull/endpoints/samsara/gps_readings.py +113 -0
- fleetpull/endpoints/samsara/idling_events.py +195 -0
- fleetpull/endpoints/samsara/odometer_readings.py +116 -0
- fleetpull/endpoints/samsara/trips.py +217 -0
- fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
- fleetpull/endpoints/samsara/vehicles.py +121 -0
- fleetpull/endpoints/shared/__init__.py +57 -0
- fleetpull/endpoints/shared/base.py +529 -0
- fleetpull/endpoints/shared/request_shape.py +227 -0
- fleetpull/endpoints/shared/resume.py +74 -0
- fleetpull/endpoints/shared/spec_builders.py +59 -0
- fleetpull/endpoints/shared/sync_mode.py +159 -0
- fleetpull/endpoints/shared/url_paths.py +149 -0
- fleetpull/exceptions.py +320 -0
- fleetpull/incremental/__init__.py +27 -0
- fleetpull/incremental/cursor.py +66 -0
- fleetpull/incremental/resolution.py +152 -0
- fleetpull/incremental/seed.py +53 -0
- fleetpull/incremental/window.py +94 -0
- fleetpull/logger/__init__.py +5 -0
- fleetpull/logger/setup.py +145 -0
- fleetpull/model_contract/__init__.py +6 -0
- fleetpull/model_contract/coercions.py +33 -0
- fleetpull/model_contract/response.py +45 -0
- fleetpull/models/__init__.py +7 -0
- fleetpull/models/geotab/__init__.py +166 -0
- fleetpull/models/geotab/annotation_log.py +109 -0
- fleetpull/models/geotab/audit.py +56 -0
- fleetpull/models/geotab/device.py +217 -0
- fleetpull/models/geotab/driver_change.py +102 -0
- fleetpull/models/geotab/duty_status_log.py +200 -0
- fleetpull/models/geotab/dvir_log.py +184 -0
- fleetpull/models/geotab/exception_event.py +135 -0
- fleetpull/models/geotab/fault_data.py +168 -0
- fleetpull/models/geotab/fill_up.py +189 -0
- fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
- fleetpull/models/geotab/fuel_tax_detail.py +150 -0
- fleetpull/models/geotab/log_record.py +68 -0
- fleetpull/models/geotab/media_file.py +133 -0
- fleetpull/models/geotab/shared.py +221 -0
- fleetpull/models/geotab/shipment_log.py +105 -0
- fleetpull/models/geotab/status_data.py +108 -0
- fleetpull/models/geotab/text_message.py +125 -0
- fleetpull/models/geotab/trip.py +162 -0
- fleetpull/models/geotab/user.py +187 -0
- fleetpull/models/motive/__init__.py +43 -0
- fleetpull/models/motive/driver_idle_rollup.py +109 -0
- fleetpull/models/motive/driving_period.py +82 -0
- fleetpull/models/motive/group.py +49 -0
- fleetpull/models/motive/idle_event.py +77 -0
- fleetpull/models/motive/shared.py +192 -0
- fleetpull/models/motive/user.py +217 -0
- fleetpull/models/motive/vehicle.py +162 -0
- fleetpull/models/motive/vehicle_location.py +127 -0
- fleetpull/models/motive/vehicle_utilization.py +128 -0
- fleetpull/models/samsara/__init__.py +108 -0
- fleetpull/models/samsara/address.py +149 -0
- fleetpull/models/samsara/asset_location.py +131 -0
- fleetpull/models/samsara/driver.py +232 -0
- fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
- fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
- fleetpull/models/samsara/engine_state.py +92 -0
- fleetpull/models/samsara/gps_reading.py +146 -0
- fleetpull/models/samsara/idling_event.py +174 -0
- fleetpull/models/samsara/odometer_reading.py +90 -0
- fleetpull/models/samsara/trip.py +199 -0
- fleetpull/models/samsara/vehicle.py +167 -0
- fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
- fleetpull/network/__init__.py +9 -0
- fleetpull/network/auth/__init__.py +15 -0
- fleetpull/network/auth/authenticate.py +271 -0
- fleetpull/network/auth/manager.py +223 -0
- fleetpull/network/auth/models.py +57 -0
- fleetpull/network/auth/strategies.py +169 -0
- fleetpull/network/classifiers/__init__.py +11 -0
- fleetpull/network/classifiers/geotab.py +145 -0
- fleetpull/network/classifiers/motive.py +79 -0
- fleetpull/network/classifiers/samsara.py +80 -0
- fleetpull/network/client/__init__.py +25 -0
- fleetpull/network/client/page.py +32 -0
- fleetpull/network/client/profile.py +27 -0
- fleetpull/network/client/registry.py +103 -0
- fleetpull/network/client/registry_base.py +150 -0
- fleetpull/network/client/runtime.py +44 -0
- fleetpull/network/client/transport.py +381 -0
- fleetpull/network/contract/__init__.py +50 -0
- fleetpull/network/contract/auth.py +48 -0
- fleetpull/network/contract/classifier.py +189 -0
- fleetpull/network/contract/envelope_fetcher.py +33 -0
- fleetpull/network/contract/envelopes.py +189 -0
- fleetpull/network/contract/outcome.py +43 -0
- fleetpull/network/contract/page_decoder.py +101 -0
- fleetpull/network/contract/request.py +97 -0
- fleetpull/network/decoders/__init__.py +40 -0
- fleetpull/network/decoders/_window_stamp.py +78 -0
- fleetpull/network/decoders/geotab.py +309 -0
- fleetpull/network/decoders/motive.py +204 -0
- fleetpull/network/decoders/motive_reports.py +114 -0
- fleetpull/network/decoders/samsara.py +343 -0
- fleetpull/network/decoders/samsara_reports.py +119 -0
- fleetpull/network/decoders/single_page.py +55 -0
- fleetpull/network/limits/__init__.py +13 -0
- fleetpull/network/limits/bucket_math.py +58 -0
- fleetpull/network/limits/limiter.py +153 -0
- fleetpull/network/limits/registry.py +96 -0
- fleetpull/network/posture/__init__.py +6 -0
- fleetpull/network/posture/client_options.py +96 -0
- fleetpull/network/retry/__init__.py +13 -0
- fleetpull/network/retry/decision.py +155 -0
- fleetpull/network/tls/__init__.py +5 -0
- fleetpull/network/tls/truststore_context.py +35 -0
- fleetpull/orchestrator/__init__.py +30 -0
- fleetpull/orchestrator/backfill.py +127 -0
- fleetpull/orchestrator/batch.py +155 -0
- fleetpull/orchestrator/bisection.py +271 -0
- fleetpull/orchestrator/drivers.py +350 -0
- fleetpull/orchestrator/entry.py +347 -0
- fleetpull/orchestrator/executors.py +128 -0
- fleetpull/orchestrator/fanout.py +163 -0
- fleetpull/orchestrator/feed_drive.py +266 -0
- fleetpull/orchestrator/metadata_projection.py +195 -0
- fleetpull/orchestrator/outcome.py +53 -0
- fleetpull/orchestrator/recording.py +85 -0
- fleetpull/orchestrator/resume.py +143 -0
- fleetpull/orchestrator/roster_harvest.py +108 -0
- fleetpull/orchestrator/roster_refresh.py +363 -0
- fleetpull/orchestrator/runner.py +262 -0
- fleetpull/orchestrator/shape_resolution.py +221 -0
- fleetpull/orchestrator/spine.py +166 -0
- fleetpull/orchestrator/streaming.py +153 -0
- fleetpull/orchestrator/unit_loop.py +273 -0
- fleetpull/orchestrator/watermark_drive.py +455 -0
- fleetpull/paths/__init__.py +13 -0
- fleetpull/paths/datasets.py +38 -0
- fleetpull/paths/partitions.py +41 -0
- fleetpull/paths/resolution.py +98 -0
- fleetpull/polars_typing/__init__.py +20 -0
- fleetpull/py.typed +0 -0
- fleetpull/records/__init__.py +19 -0
- fleetpull/records/convert.py +53 -0
- fleetpull/records/dataframe.py +63 -0
- fleetpull/records/event_time.py +70 -0
- fleetpull/records/fields.py +190 -0
- fleetpull/records/flatten.py +54 -0
- fleetpull/records/roster_members.py +74 -0
- fleetpull/records/schema.py +73 -0
- fleetpull/records/validation.py +62 -0
- fleetpull/resources/__init__.py +10 -0
- fleetpull/resources/config.example.yaml +222 -0
- fleetpull/roster/__init__.py +16 -0
- fleetpull/roster/definition.py +46 -0
- fleetpull/roster/key.py +40 -0
- fleetpull/roster/registry.py +93 -0
- fleetpull/state/__init__.py +36 -0
- fleetpull/state/cursors.py +417 -0
- fleetpull/state/database.py +463 -0
- fleetpull/state/migrations.py +430 -0
- fleetpull/state/reconcile.py +127 -0
- fleetpull/state/rosters.py +159 -0
- fleetpull/state/run_ledger.py +572 -0
- fleetpull/state/work_units.py +648 -0
- fleetpull/storage/__init__.py +30 -0
- fleetpull/storage/append.py +187 -0
- fleetpull/storage/atomic.py +161 -0
- fleetpull/storage/files.py +176 -0
- fleetpull/storage/frames.py +98 -0
- fleetpull/storage/metadata.py +161 -0
- fleetpull/storage/partitioned.py +205 -0
- fleetpull/storage/pruning.py +169 -0
- fleetpull/storage/read.py +35 -0
- fleetpull/storage/result.py +34 -0
- fleetpull/storage/single_file.py +130 -0
- fleetpull/storage/splitting.py +88 -0
- fleetpull/storage/staging.py +178 -0
- fleetpull/storage/writers.py +145 -0
- fleetpull/timing/__init__.py +21 -0
- fleetpull/timing/canon.py +92 -0
- fleetpull/timing/clock.py +192 -0
- fleetpull/timing/codec.py +122 -0
- fleetpull/timing/sleeper.py +65 -0
- fleetpull/vocabulary/__init__.py +16 -0
- fleetpull/vocabulary/json_types.py +20 -0
- fleetpull/vocabulary/provider.py +29 -0
- fleetpull/vocabulary/quota_scope.py +47 -0
- fleetpull/vocabulary/response_category.py +32 -0
- fleetpull-0.1.0.dist-info/METADATA +248 -0
- fleetpull-0.1.0.dist-info/RECORD +252 -0
- fleetpull-0.1.0.dist-info/WHEEL +5 -0
- fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
- fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
- fleetpull-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# src/fleetpull/storage/metadata.py
|
|
2
|
+
"""The per-endpoint ``metadata.json`` projection: render and write the snapshot.
|
|
3
|
+
|
|
4
|
+
``metadata.json`` is a generated human-readable snapshot of one endpoint's
|
|
5
|
+
last successful run, written beside its data after the run commits (DESIGN
|
|
6
|
+
§3). It is never read by the program — SQLite stays the single source of
|
|
7
|
+
truth (§5) — so this module is a pure projection surface: a frozen carrier
|
|
8
|
+
of already-committed facts (``MetadataSnapshot``), a deterministic JSON
|
|
9
|
+
render, and an atomic file write. The orchestrator supplies every fact as a
|
|
10
|
+
plain value (strings, counts, datetimes); no SQLite, no state types, no
|
|
11
|
+
cursor union crosses this boundary, which is what keeps the §11
|
|
12
|
+
storage/state separation intact.
|
|
13
|
+
|
|
14
|
+
The write is temp-then-rename atomic (the same same-filesystem rename
|
|
15
|
+
doctrine as ``atomic.py``) and deliberately does not create the endpoint
|
|
16
|
+
directory: an absent directory means no data ever landed there, an upstream
|
|
17
|
+
bug that must surface as the ``OSError`` the caller's posture handles, not
|
|
18
|
+
be papered over with a metadata file for a dataset that does not exist.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import date, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from fleetpull.storage.atomic import atomic_write_text
|
|
27
|
+
from fleetpull.timing import to_iso8601
|
|
28
|
+
|
|
29
|
+
__all__: list[str] = [
|
|
30
|
+
'MetadataSnapshot',
|
|
31
|
+
'render_metadata_json',
|
|
32
|
+
'write_metadata_json',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# The per-endpoint snapshot file name (DESIGN §3).
|
|
36
|
+
_METADATA_FILE_NAME: str = 'metadata.json'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class MetadataSnapshot:
|
|
41
|
+
"""One endpoint's last-successful-run facts, ready to render.
|
|
42
|
+
|
|
43
|
+
Every field is a plain value the orchestrator has already committed
|
|
44
|
+
elsewhere (ledger, cursor store, write report) — this carrier holds the
|
|
45
|
+
projection, never the state types themselves.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
provider: The provider directory name (e.g. ``'motive'``).
|
|
49
|
+
endpoint: The endpoint name (e.g. ``'vehicles'``).
|
|
50
|
+
sync_mode: The endpoint's sync-mode label — ``'snapshot'``,
|
|
51
|
+
``'watermark'``, or ``'feed'``.
|
|
52
|
+
generated_at: The projection instant, timezone-aware UTC.
|
|
53
|
+
records_fetched: Records fetched across the run.
|
|
54
|
+
rows_written: Rows written to disk this run.
|
|
55
|
+
duplicates_dropped: Exact-duplicate rows removed at write time.
|
|
56
|
+
files_written: Parquet files written this run.
|
|
57
|
+
deleted_partitions: The date partitions pruned this run.
|
|
58
|
+
window_start: The run's resolved window start, or ``None`` when the
|
|
59
|
+
run had no window (a snapshot run).
|
|
60
|
+
window_end: The run's resolved window end, or ``None`` when the run
|
|
61
|
+
had no window.
|
|
62
|
+
cursor_kind: The stored cursor's kind (``'date_watermark'`` or
|
|
63
|
+
``'feed_token'``), or ``None`` when no cursor is stored.
|
|
64
|
+
cursor_value: The stored cursor's serialized value, or ``None`` when
|
|
65
|
+
no cursor is stored.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
provider: str
|
|
69
|
+
endpoint: str
|
|
70
|
+
sync_mode: str
|
|
71
|
+
generated_at: datetime
|
|
72
|
+
records_fetched: int
|
|
73
|
+
rows_written: int
|
|
74
|
+
duplicates_dropped: int
|
|
75
|
+
files_written: int
|
|
76
|
+
deleted_partitions: tuple[date, ...]
|
|
77
|
+
window_start: datetime | None
|
|
78
|
+
window_end: datetime | None
|
|
79
|
+
cursor_kind: str | None
|
|
80
|
+
cursor_value: str | None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _optional_iso8601(moment: datetime | None) -> str | None:
|
|
84
|
+
"""Render an optional UTC datetime as ISO-8601, passing ``None`` through.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
moment: A timezone-aware UTC datetime, or ``None``.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The ISO-8601 ``Z`` string, or ``None``.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: ``moment`` is naive or non-UTC (from ``to_iso8601``).
|
|
94
|
+
"""
|
|
95
|
+
return None if moment is None else to_iso8601(moment)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_metadata_json(snapshot: MetadataSnapshot) -> str:
|
|
99
|
+
"""Render a snapshot as the ``metadata.json`` document text.
|
|
100
|
+
|
|
101
|
+
A pure, deterministic render: datetimes via the timing codec's ISO-8601
|
|
102
|
+
form, dates via ``isoformat``, two-space indentation, one trailing
|
|
103
|
+
newline. ``schema_version`` names the document shape so a human diffing
|
|
104
|
+
files across package versions can tell a shape change from a data change.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
snapshot: The committed run facts to render.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The complete JSON document text, trailing newline included.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: A snapshot datetime is naive or non-UTC (from the codec).
|
|
114
|
+
"""
|
|
115
|
+
cursor = (
|
|
116
|
+
None
|
|
117
|
+
if snapshot.cursor_kind is None
|
|
118
|
+
else {'kind': snapshot.cursor_kind, 'value': snapshot.cursor_value}
|
|
119
|
+
)
|
|
120
|
+
document = {
|
|
121
|
+
'schema_version': 1,
|
|
122
|
+
'provider': snapshot.provider,
|
|
123
|
+
'endpoint': snapshot.endpoint,
|
|
124
|
+
'sync_mode': snapshot.sync_mode,
|
|
125
|
+
'generated_at': to_iso8601(snapshot.generated_at),
|
|
126
|
+
'last_run': {
|
|
127
|
+
'records_fetched': snapshot.records_fetched,
|
|
128
|
+
'rows_written': snapshot.rows_written,
|
|
129
|
+
'duplicates_dropped': snapshot.duplicates_dropped,
|
|
130
|
+
'files_written': snapshot.files_written,
|
|
131
|
+
'deleted_partitions': [
|
|
132
|
+
deleted_date.isoformat() for deleted_date in snapshot.deleted_partitions
|
|
133
|
+
],
|
|
134
|
+
'window_start': _optional_iso8601(snapshot.window_start),
|
|
135
|
+
'window_end': _optional_iso8601(snapshot.window_end),
|
|
136
|
+
},
|
|
137
|
+
'cursor': cursor,
|
|
138
|
+
}
|
|
139
|
+
return json.dumps(document, indent=2) + '\n'
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def write_metadata_json(endpoint_directory: Path, text: str) -> None:
|
|
143
|
+
"""Atomically write ``text`` as the endpoint directory's ``metadata.json``.
|
|
144
|
+
|
|
145
|
+
``atomic_write_text``'s temp-then-rename, aimed at the projection's fixed
|
|
146
|
+
file name. The endpoint directory is deliberately not created: an absent
|
|
147
|
+
directory means no data ever landed — an upstream bug surfaced here as
|
|
148
|
+
``OSError``, not silenced by a ``mkdir``.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
endpoint_directory: The endpoint's existing output directory.
|
|
152
|
+
text: The complete document text (from ``render_metadata_json``).
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
OSError: The write or rename failed — including a missing endpoint
|
|
156
|
+
directory (the temp is cleaned up first).
|
|
157
|
+
|
|
158
|
+
Side Effects:
|
|
159
|
+
Writes and renames files inside ``endpoint_directory``.
|
|
160
|
+
"""
|
|
161
|
+
atomic_write_text(text, endpoint_directory / _METADATA_FILE_NAME)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# src/fleetpull/storage/partitioned.py
|
|
2
|
+
"""The date-partitioned writer family: hive ``date=YYYY-MM-DD`` partitions.
|
|
3
|
+
|
|
4
|
+
``PartitionedWriter`` is the family's shared substance -- stage each piece as
|
|
5
|
+
date-split shards, fold each touched date's shards into its ``part.parquet``,
|
|
6
|
+
prune where the cell prunes -- and ``WatermarkPartitionedWriter`` is the
|
|
7
|
+
shipped ``(DATE_PARTITIONED, WatermarkMode)`` cell (replace each covered
|
|
8
|
+
partition, prune the covered-but-empty dates; DESIGN §3/§4). The feed cell is
|
|
9
|
+
deliberately NOT in this family: it appends numbered part files with per-write
|
|
10
|
+
durability and no window (``storage/append.py``). ``select_writer``
|
|
11
|
+
(``storage/writers.py``) is the routing face that constructs these.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from datetime import date
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import polars as pl
|
|
19
|
+
|
|
20
|
+
from fleetpull.incremental import DateWindow
|
|
21
|
+
from fleetpull.storage.files import partition_part_file
|
|
22
|
+
from fleetpull.storage.pruning import prune_window_partitions, window_dates
|
|
23
|
+
from fleetpull.storage.read import read_parquet_if_exists
|
|
24
|
+
from fleetpull.storage.result import WriteResult
|
|
25
|
+
from fleetpull.storage.staging import (
|
|
26
|
+
clear_partition_staging,
|
|
27
|
+
compact_partition,
|
|
28
|
+
stage_shard,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__: list[str] = ['PartitionedWriter', 'WatermarkPartitionedWriter']
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PartitionedWriter(ABC):
|
|
35
|
+
"""Writers whose dataset is hive ``date=YYYY-MM-DD`` partitions.
|
|
36
|
+
|
|
37
|
+
Shared substance: each ``write`` stages its piece as date-split shards
|
|
38
|
+
(``stage_shard``); ``finalize`` folds each touched date's shards into that
|
|
39
|
+
date's ``part.parquet`` (``compact_partition``) and reports. Two per-cell
|
|
40
|
+
decisions are the subclass's: whether compaction folds in the existing
|
|
41
|
+
partition (``_reads_existing`` -- fold-in cells would, replace cells do
|
|
42
|
+
not), and whether the run prunes the covered-but-empty dates (``_prunes``
|
|
43
|
+
-- a watermark refresh authoritatively replaces its window, so it prunes).
|
|
44
|
+
The ABC owns the staging and the finalize orchestration; the per-partition
|
|
45
|
+
compaction is the shared ``compact_partition`` it drives.
|
|
46
|
+
|
|
47
|
+
``write`` carries the window tripwire: every staged partition date must lie
|
|
48
|
+
in ``window_dates(window)``. Upstream, the resume window is day-aligned at
|
|
49
|
+
resolution and the orchestrator window-filters each batch before writing,
|
|
50
|
+
so a staged date outside the window means an upstream boundary was missed
|
|
51
|
+
-- the require-inside half of the normalize-at-boundary doctrine (the
|
|
52
|
+
canonical-UTC stance), raised loudly here rather than silently replacing or
|
|
53
|
+
pruning a partition the run had no right to touch.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def _reads_existing(self) -> bool:
|
|
59
|
+
"""Whether compaction folds the existing ``part.parquet`` into the result."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def _prunes(self) -> bool:
|
|
65
|
+
"""Whether ``finalize`` deletes the window's covered-but-empty partitions."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
target_dir: Path,
|
|
71
|
+
event_time_column: str,
|
|
72
|
+
window: DateWindow,
|
|
73
|
+
*,
|
|
74
|
+
drop_duplicates: bool = True,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Bind the writer to an endpoint directory, event-time column, and window.
|
|
77
|
+
|
|
78
|
+
Clears any stale shards a prior crashed run left under the window's covered
|
|
79
|
+
dates before staging anything, so a later ``compact_partition`` folds only
|
|
80
|
+
this run's shards, not a superseded row's pre-edit version. A covered date
|
|
81
|
+
the clear empties (a crash before any ``part.parquet`` existed) has its
|
|
82
|
+
now-empty ``date=`` directory removed too, upholding the
|
|
83
|
+
no-empty-partition-directory invariant (DESIGN §3/§14).
|
|
84
|
+
|
|
85
|
+
Assumes no overlapping writer for the same endpoint and window runs
|
|
86
|
+
concurrently: the construction-time clear is destructive, so overlapping
|
|
87
|
+
runs could delete each other's live staging. Orchestration must prevent
|
|
88
|
+
overlapping runs for one endpoint.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
target_dir: The endpoint directory holding the ``date=`` partitions.
|
|
92
|
+
event_time_column: The UTC datetime column whose date keys the
|
|
93
|
+
partitions.
|
|
94
|
+
window: The run's half-open resume window -- the prune's bound and the
|
|
95
|
+
covered-date set the construction-time staging clear sweeps.
|
|
96
|
+
drop_duplicates: Whether compaction drops exact-duplicate rows
|
|
97
|
+
(``storage.drop_exact_duplicates``, default on).
|
|
98
|
+
|
|
99
|
+
Side Effects:
|
|
100
|
+
Removes any existing ``staging/`` directory under the window's covered
|
|
101
|
+
dates, and any ``date=`` directory the clear leaves empty.
|
|
102
|
+
"""
|
|
103
|
+
self._target_dir = target_dir
|
|
104
|
+
self._event_time_column = event_time_column
|
|
105
|
+
self._window = window
|
|
106
|
+
self._drop_duplicates = drop_duplicates
|
|
107
|
+
self._covered_dates: frozenset[date] = frozenset(window_dates(window))
|
|
108
|
+
self._written_dates: set[date] = set()
|
|
109
|
+
clear_partition_staging(target_dir, self._covered_dates)
|
|
110
|
+
|
|
111
|
+
def write(self, frame: pl.DataFrame) -> None:
|
|
112
|
+
"""Stage one fetched piece as date-split shards, inside the window only.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
frame: One fetched piece (e.g. one vehicle's rows over the window).
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: A staged partition date lies outside the window's
|
|
119
|
+
covered dates -- an upstream window filter missed rows (the
|
|
120
|
+
interior tripwire; see the class docstring). The run fails
|
|
121
|
+
loudly; the orphaned out-of-window shards are inert (``.shard``
|
|
122
|
+
files are invisible to hive ``*.parquet`` reads) until a later
|
|
123
|
+
run whose window covers that date sweeps them at construction.
|
|
124
|
+
|
|
125
|
+
Side Effects:
|
|
126
|
+
Writes ``.shard`` files under the touched dates' staging directories.
|
|
127
|
+
"""
|
|
128
|
+
touched_dates = stage_shard(self._target_dir, frame, self._event_time_column)
|
|
129
|
+
out_of_window = touched_dates - self._covered_dates
|
|
130
|
+
if out_of_window:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f'staged partition dates outside the resume window: '
|
|
133
|
+
f'{sorted(out_of_window)} not in {sorted(self._covered_dates)} '
|
|
134
|
+
f'-- an upstream window filter missed these rows'
|
|
135
|
+
)
|
|
136
|
+
self._written_dates.update(touched_dates)
|
|
137
|
+
|
|
138
|
+
def finalize(self) -> WriteResult:
|
|
139
|
+
"""Compact each partition, clear staging, prune if the cell prunes, and report.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The write report -- rows and duplicates summed across the compacted
|
|
143
|
+
partitions, ``files_written`` the partition count, and
|
|
144
|
+
``deleted_partitions`` the pruned dates (empty when the cell does not
|
|
145
|
+
prune).
|
|
146
|
+
|
|
147
|
+
Side Effects:
|
|
148
|
+
Writes each touched ``part.parquet`` and clears its staging; deletes
|
|
149
|
+
the pruned partition directories.
|
|
150
|
+
"""
|
|
151
|
+
rows_written = 0
|
|
152
|
+
duplicates_dropped = 0
|
|
153
|
+
for partition_date in sorted(self._written_dates):
|
|
154
|
+
existing = (
|
|
155
|
+
read_parquet_if_exists(
|
|
156
|
+
partition_part_file(self._target_dir, partition_date)
|
|
157
|
+
)
|
|
158
|
+
if self._reads_existing
|
|
159
|
+
else None
|
|
160
|
+
)
|
|
161
|
+
result = compact_partition(
|
|
162
|
+
self._target_dir,
|
|
163
|
+
partition_date,
|
|
164
|
+
existing,
|
|
165
|
+
drop_duplicates=self._drop_duplicates,
|
|
166
|
+
)
|
|
167
|
+
rows_written += result.rows_written
|
|
168
|
+
duplicates_dropped += result.duplicates_dropped
|
|
169
|
+
clear_partition_staging(self._target_dir, self._written_dates)
|
|
170
|
+
deleted_partitions = (
|
|
171
|
+
tuple(
|
|
172
|
+
prune_window_partitions(
|
|
173
|
+
self._target_dir, self._window, self._written_dates
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
if self._prunes
|
|
177
|
+
else ()
|
|
178
|
+
)
|
|
179
|
+
return WriteResult(
|
|
180
|
+
rows_written=rows_written,
|
|
181
|
+
duplicates_dropped=duplicates_dropped,
|
|
182
|
+
files_written=len(self._written_dates),
|
|
183
|
+
deleted_partitions=deleted_partitions,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class WatermarkPartitionedWriter(PartitionedWriter):
|
|
188
|
+
"""``watermark`` + ``date_partitioned``: replace each covered partition, prune.
|
|
189
|
+
|
|
190
|
+
The floored window refetches each covered date in full, so compaction replaces
|
|
191
|
+
that date's ``part.parquet`` outright -- no existing read -- and the run prunes
|
|
192
|
+
the covered dates that returned empty (a provider that deleted or edited
|
|
193
|
+
records). Late, corrected, and deleted records all land through replacement,
|
|
194
|
+
never a row-level merge (DESIGN §3/§4).
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def _reads_existing(self) -> bool:
|
|
199
|
+
"""Replace, never fold in the prior partition."""
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def _prunes(self) -> bool:
|
|
204
|
+
"""The window is authoritatively replaced, so empty-refetch dates prune."""
|
|
205
|
+
return True
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# src/fleetpull/storage/pruning.py
|
|
2
|
+
"""Date-partition pruning: drop the partitions a refresh window covers but did
|
|
3
|
+
not write.
|
|
4
|
+
|
|
5
|
+
The delete half of the date-partitioned write path's two-step (write the fetched
|
|
6
|
+
partitions, then delete the covered-but-unwritten ones). A watermark refresh
|
|
7
|
+
authoritatively replaces its window ``[start, end)``: every ``date=`` partition
|
|
8
|
+
the window covers must, after the run, hold exactly this run's data for that date.
|
|
9
|
+
A covered date the fetch did not write is therefore stale -- the provider deleted
|
|
10
|
+
or edited every record it once held -- so its partition directory must go, the
|
|
11
|
+
directory-grain analogue of the row-level delete-by-window (DESIGN §3/§4).
|
|
12
|
+
|
|
13
|
+
Four stateless single-concern functions compose the prune, none aware of *when* in
|
|
14
|
+
the run they run. The driver sequences the delete after the full per-vehicle
|
|
15
|
+
fan-out, once ``written_dates`` is complete -- a partition holds the whole fleet's
|
|
16
|
+
rows for its date, so the written set is not final until the last vehicle is
|
|
17
|
+
processed -- but these functions take the finished set as a given:
|
|
18
|
+
|
|
19
|
+
- ``window_dates`` -- the covered calendar dates of a window (pure date math).
|
|
20
|
+
- ``existing_partition_dates`` -- which of a candidate date set exist on disk (the
|
|
21
|
+
filesystem probe, candidate-driven, never a directory scan).
|
|
22
|
+
- ``delete_partition`` -- remove one partition directory (the single delete).
|
|
23
|
+
- ``prune_window_partitions`` -- the composer: covered, on disk, minus written,
|
|
24
|
+
deleted.
|
|
25
|
+
|
|
26
|
+
The set arithmetic is ``window_dates(window) ∩ {on disk} - {written}``. The
|
|
27
|
+
``∩ window_dates`` term is the safety leash: it bounds the delete to the refresh
|
|
28
|
+
window, so history *outside* the window is never touched. Deleting on
|
|
29
|
+
``existed - written`` alone would erase every partition outside the window -- a
|
|
30
|
+
data-loss bug -- which is why the window intersection lives inside the composer,
|
|
31
|
+
enforced once here rather than trusted to every caller. The probe is
|
|
32
|
+
candidate-driven (``is_dir`` only the window's own dates), so its cost is
|
|
33
|
+
O(window), never O(dataset): listing the endpoint directory is the O(dataset) scan
|
|
34
|
+
partitioning exists to avoid.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import shutil
|
|
38
|
+
from collections.abc import Collection
|
|
39
|
+
from datetime import date, timedelta
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from fleetpull.incremental import DateWindow
|
|
43
|
+
from fleetpull.storage.files import partition_dir
|
|
44
|
+
|
|
45
|
+
__all__: list[str] = [
|
|
46
|
+
'delete_partition',
|
|
47
|
+
'existing_partition_dates',
|
|
48
|
+
'prune_window_partitions',
|
|
49
|
+
'window_dates',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def window_dates(window: DateWindow) -> list[date]:
|
|
54
|
+
"""The calendar dates a half-open ``[start, end)`` window covers.
|
|
55
|
+
|
|
56
|
+
A partition ``date=d`` is covered iff some instant of that day lies in
|
|
57
|
+
``[start, end)`` -- the dates ``start.date()`` through the window's
|
|
58
|
+
``last_covered_date`` inclusive (the half-open edge and its one-microsecond
|
|
59
|
+
derivation are stated once, on ``DateWindow``).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
window: The half-open ``[start, end)`` resume window.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The covered dates in ascending order, ``start.date()`` first. Always at
|
|
66
|
+
least one date, since ``window`` guarantees ``start < end``.
|
|
67
|
+
|
|
68
|
+
Side Effects:
|
|
69
|
+
None -- pure function.
|
|
70
|
+
"""
|
|
71
|
+
first = window.start.date()
|
|
72
|
+
last = window.last_covered_date
|
|
73
|
+
span_days = (last - first).days + 1
|
|
74
|
+
return [first + timedelta(days=offset) for offset in range(span_days)]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def existing_partition_dates(
|
|
78
|
+
endpoint_dir: Path, candidate_dates: Collection[date]
|
|
79
|
+
) -> set[date]:
|
|
80
|
+
"""Which of ``candidate_dates`` have a partition directory on disk.
|
|
81
|
+
|
|
82
|
+
Candidate-driven: probes only the ``date=`` directories for the dates handed
|
|
83
|
+
in (``is_dir`` per candidate), never lists ``endpoint_dir``. The cost is
|
|
84
|
+
therefore O(candidates), not O(dataset) -- the point of anchoring the prune to
|
|
85
|
+
the refresh window rather than scanning the tree (DESIGN §3).
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
endpoint_dir: The endpoint directory holding the ``date=`` partitions.
|
|
89
|
+
candidate_dates: The dates to probe (the window's covered dates).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The subset of ``candidate_dates`` whose partition directory exists. Empty
|
|
93
|
+
when none exist (e.g. a first run, or an endpoint directory not yet
|
|
94
|
+
created).
|
|
95
|
+
|
|
96
|
+
Side Effects:
|
|
97
|
+
None -- reads directory existence only; creates and writes nothing.
|
|
98
|
+
"""
|
|
99
|
+
return {
|
|
100
|
+
candidate_date
|
|
101
|
+
for candidate_date in candidate_dates
|
|
102
|
+
if partition_dir(endpoint_dir, candidate_date).is_dir()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def delete_partition(endpoint_dir: Path, partition_date: date) -> None:
|
|
107
|
+
"""Remove one date-partition directory and everything under it.
|
|
108
|
+
|
|
109
|
+
Deletes the whole ``date=YYYY-MM-DD`` directory (the part file and any temp
|
|
110
|
+
siblings), not just the part file, so no empty partition directory is left
|
|
111
|
+
behind -- a present-but-empty ``date=`` directory would read as a date with
|
|
112
|
+
data when scanned, the invariant ``split_by_date`` upholds by never creating
|
|
113
|
+
empty partitions (DESIGN §3).
|
|
114
|
+
|
|
115
|
+
Strict: the directory must exist. A missing directory is a caller bug -- the
|
|
116
|
+
composer deletes only dates ``existing_partition_dates`` just confirmed present
|
|
117
|
+
-- and a silent no-op on a *delete* would hide that logic error, so the
|
|
118
|
+
underlying ``rmtree`` is allowed to raise.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
endpoint_dir: The endpoint directory holding the ``date=`` partitions.
|
|
122
|
+
partition_date: The calendar date whose partition directory to remove.
|
|
123
|
+
|
|
124
|
+
Side Effects:
|
|
125
|
+
Recursively deletes the partition directory from the filesystem.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
FileNotFoundError: The partition directory does not exist.
|
|
129
|
+
OSError: The directory could not be removed (permissions, etc.).
|
|
130
|
+
"""
|
|
131
|
+
shutil.rmtree(partition_dir(endpoint_dir, partition_date))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def prune_window_partitions(
|
|
135
|
+
endpoint_dir: Path, window: DateWindow, written_dates: Collection[date]
|
|
136
|
+
) -> list[date]:
|
|
137
|
+
"""Delete the partitions ``window`` covers but this run did not write.
|
|
138
|
+
|
|
139
|
+
Computes the stale set itself -- ``window_dates(window)`` intersected with the
|
|
140
|
+
partitions present on disk, minus ``written_dates`` -- and deletes each. The
|
|
141
|
+
intersection with the window is the safety leash that keeps the delete inside
|
|
142
|
+
the refresh window (DESIGN §3); it lives here, not in the caller, so the bound
|
|
143
|
+
is enforced in one tested place. Usually returns empty: in steady state every
|
|
144
|
+
covered date gets data, so nothing is stale. That rarity is exactly why the
|
|
145
|
+
path must be airtight -- a directory delete that almost never fires is where a
|
|
146
|
+
latent bug hides.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
endpoint_dir: The endpoint directory holding the ``date=`` partitions.
|
|
150
|
+
window: The half-open ``[start, end)`` window this run refreshed.
|
|
151
|
+
written_dates: The dates this run wrote a partition for (the keys of the
|
|
152
|
+
per-date split). Order and duplicates do not matter.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The dates whose partitions were deleted, ascending. Empty when no covered
|
|
156
|
+
partition was stale.
|
|
157
|
+
|
|
158
|
+
Side Effects:
|
|
159
|
+
Deletes stale partition directories from the filesystem.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
OSError: A stale partition directory could not be removed.
|
|
163
|
+
"""
|
|
164
|
+
covered = window_dates(window)
|
|
165
|
+
present = existing_partition_dates(endpoint_dir, covered)
|
|
166
|
+
stale = sorted(present - set(written_dates))
|
|
167
|
+
for stale_date in stale:
|
|
168
|
+
delete_partition(endpoint_dir, stale_date)
|
|
169
|
+
return stale
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# src/fleetpull/storage/read.py
|
|
2
|
+
"""Existence-tolerant parquet reads: the read sibling of the atomic write.
|
|
3
|
+
|
|
4
|
+
A writer that combines this run's records with what is already on disk reads the
|
|
5
|
+
prior file through here; a first run -- no file yet -- comes back as ``None``
|
|
6
|
+
rather than an error, so the writer's combine handles the empty case the same way
|
|
7
|
+
every time. The built cells all replace or append rather than combine (the
|
|
8
|
+
snapshot writer rewrites the single file; the partitioned watermark cell replaces
|
|
9
|
+
its partitions; the append-log feed cell only ever adds new part files), so no
|
|
10
|
+
writer reads today; the one combining cell -- the single-file watermark cell --
|
|
11
|
+
remains unbuilt.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import polars as pl
|
|
17
|
+
|
|
18
|
+
__all__: list[str] = ['read_parquet_if_exists']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_parquet_if_exists(path: Path) -> pl.DataFrame | None:
|
|
22
|
+
"""Read a parquet file, or ``None`` if it does not exist.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
path: The parquet file to read.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The frame, or ``None`` when ``path`` does not exist (a first run).
|
|
29
|
+
|
|
30
|
+
Side Effects:
|
|
31
|
+
Reads ``path`` if present.
|
|
32
|
+
"""
|
|
33
|
+
if path.exists():
|
|
34
|
+
return pl.read_parquet(path)
|
|
35
|
+
return None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# src/fleetpull/storage/result.py
|
|
2
|
+
"""The storage write report.
|
|
3
|
+
|
|
4
|
+
``WriteResult`` is what a ``DatasetWriter.finalize`` returns -- the run ledger
|
|
5
|
+
reads it (via the orchestrator, not storage): the per-run write stats a
|
|
6
|
+
consumer inspects to know what a sync produced.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import date
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = ['WriteResult']
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class WriteResult:
|
|
17
|
+
"""What one endpoint's write produced this run.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
rows_written: Rows written to disk this run. For ``single`` (a full
|
|
21
|
+
rewrite) this is the whole dataset; for ``date_partitioned`` it is the
|
|
22
|
+
rows across the touched partitions, not the dataset total.
|
|
23
|
+
duplicates_dropped: Exact-duplicate rows removed at write time.
|
|
24
|
+
files_written: Parquet files written -- ``1`` for ``single``, the count of
|
|
25
|
+
touched partitions for ``date_partitioned``.
|
|
26
|
+
deleted_partitions: The date partitions deleted this run -- the
|
|
27
|
+
covered-but-empty dates a date-partitioned watermark refresh prunes.
|
|
28
|
+
Empty for every other cell.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
rows_written: int
|
|
32
|
+
duplicates_dropped: int
|
|
33
|
+
files_written: int
|
|
34
|
+
deleted_partitions: tuple[date, ...] = ()
|