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,187 @@
|
|
|
1
|
+
# src/fleetpull/storage/append.py
|
|
2
|
+
"""The append-log writer: the feed cell's accumulate-only write path.
|
|
3
|
+
|
|
4
|
+
``FeedAppendWriter`` is the ``(APPEND_LOG, FeedMode)`` cell (DESIGN §3/§4).
|
|
5
|
+
Unlike every other writer, each ``write`` is durable the moment it returns:
|
|
6
|
+
the piece's rows are split by event date and each date's rows land as the
|
|
7
|
+
next-numbered ``part-NNNNN.parquet`` in that date's partition, through the
|
|
8
|
+
atomic temp-then-rename write. That per-write durability is what the feed
|
|
9
|
+
drive's per-page crash order stands on — a page's parquet must be on disk
|
|
10
|
+
before its token commits (§14's I1), so the feed cell cannot defer its bytes
|
|
11
|
+
to ``finalize`` the way the staged and single-file cells do; ``finalize``
|
|
12
|
+
only reports.
|
|
13
|
+
|
|
14
|
+
The cell is append-only in the strictest sense (§14's I3): it writes new
|
|
15
|
+
part files and never reads, rewrites, or deletes an existing one. Part
|
|
16
|
+
numbering is max-existing + 1, scanned per write — sound because parquet
|
|
17
|
+
merge per endpoint is single-writer (DESIGN §5) — and the chosen target is
|
|
18
|
+
still existence-checked before the rename, so a violated single-writer
|
|
19
|
+
assumption clobbers nothing and fails loudly instead.
|
|
20
|
+
|
|
21
|
+
Deliberately NO write-time exact dedup, unlike every other cell: the dataset
|
|
22
|
+
contract is stored-as-emitted (DESIGN §4) — re-emitted versions and a crash
|
|
23
|
+
window's refetched page land as new rows, and the consumer reconciles
|
|
24
|
+
calculated feeds by ``(id, max version)`` and active feeds by ``id``.
|
|
25
|
+
Deduping a crash-window duplicate would require reading and rewriting
|
|
26
|
+
already-landed part files, which is exactly what I3 forbids; within-page
|
|
27
|
+
duplicates are the provider's emission, kept verbatim.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Final
|
|
33
|
+
|
|
34
|
+
import polars as pl
|
|
35
|
+
|
|
36
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
37
|
+
from fleetpull.storage.atomic import atomic_write_parquet
|
|
38
|
+
from fleetpull.storage.files import append_part_file, partition_dir
|
|
39
|
+
from fleetpull.storage.result import WriteResult
|
|
40
|
+
from fleetpull.storage.splitting import split_by_date
|
|
41
|
+
|
|
42
|
+
__all__: list[str] = ['FeedAppendWriter']
|
|
43
|
+
|
|
44
|
+
# The strict shape of an append-log part file name. The scan parses numbers
|
|
45
|
+
# out of exactly this shape; anything else matching 'part-*.parquet' in an
|
|
46
|
+
# append-log partition is a foreign file and fails loudly (the corruption
|
|
47
|
+
# stance) rather than silently skewing the numbering.
|
|
48
|
+
_APPEND_PART_PATTERN: Final[re.Pattern[str]] = re.compile(r'part-(\d+)\.parquet\Z')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _next_part_number(partition_directory: Path) -> int:
|
|
52
|
+
"""The next append part number for one partition: max existing + 1.
|
|
53
|
+
|
|
54
|
+
Scans only the given partition directory (never the endpoint tree), so
|
|
55
|
+
the cost is O(parts in this partition). A missing directory — the
|
|
56
|
+
partition's first-ever part — starts at 1.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
partition_directory: The ``date=YYYY-MM-DD`` directory to scan.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The next part number (>= 1).
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: A ``part-*.parquet`` file in the partition does not match
|
|
66
|
+
the strict ``part-NNNNN.parquet`` shape — a foreign file in an
|
|
67
|
+
append-log partition is a dataset-layout violation, surfaced
|
|
68
|
+
loudly rather than silently skewing the numbering.
|
|
69
|
+
|
|
70
|
+
Side Effects:
|
|
71
|
+
None -- reads directory listings only.
|
|
72
|
+
"""
|
|
73
|
+
if not partition_directory.is_dir():
|
|
74
|
+
return 1
|
|
75
|
+
highest = 0
|
|
76
|
+
for part_path in partition_directory.glob('part-*.parquet'):
|
|
77
|
+
match = _APPEND_PART_PATTERN.fullmatch(part_path.name)
|
|
78
|
+
if match is None:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f'foreign part file in append-log partition: {part_path} '
|
|
81
|
+
f'does not match part-NNNNN.parquet'
|
|
82
|
+
)
|
|
83
|
+
highest = max(highest, int(match.group(1)))
|
|
84
|
+
return highest + 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FeedAppendWriter:
|
|
88
|
+
"""``feed`` + ``append_log``: append numbered part files, touch nothing else.
|
|
89
|
+
|
|
90
|
+
Each ``write`` lands its piece durably (one new part file per event date
|
|
91
|
+
present), so the feed drive can commit that piece's token immediately
|
|
92
|
+
after; ``finalize`` reports the accumulated counts and writes nothing.
|
|
93
|
+
``duplicates_dropped`` is always ``0`` — the cell performs no write-time
|
|
94
|
+
dedup by design (stored-as-emitted; the module docstring carries the
|
|
95
|
+
rationale).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, target_dir: Path, event_time_column: str) -> None:
|
|
99
|
+
"""Bind the writer to an endpoint directory and event-time column.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
target_dir: The endpoint directory holding the ``date=`` partitions.
|
|
103
|
+
event_time_column: The UTC datetime column whose date routes each
|
|
104
|
+
row into its partition.
|
|
105
|
+
|
|
106
|
+
Side Effects:
|
|
107
|
+
None -- nothing is swept or cleared; prior parts are never touched.
|
|
108
|
+
"""
|
|
109
|
+
self._target_dir = target_dir
|
|
110
|
+
self._event_time_column = event_time_column
|
|
111
|
+
self._rows_written = 0
|
|
112
|
+
self._files_written = 0
|
|
113
|
+
|
|
114
|
+
def write(self, frame: pl.DataFrame) -> None:
|
|
115
|
+
"""Append one piece durably: a new numbered part per event date present.
|
|
116
|
+
|
|
117
|
+
Splits ``frame`` by the UTC date of the event-time column and writes
|
|
118
|
+
each date's rows as that partition's next ``part-NNNNN.parquet``,
|
|
119
|
+
atomically and DURABLY (the durable-rename recipe: the token commit
|
|
120
|
+
is fsynced, so the page it covers must be too -- a power loss never
|
|
121
|
+
persists a token past unwritten data). An empty frame writes nothing (an at-head feed page). The
|
|
122
|
+
write is durable when this returns — the caller may commit the
|
|
123
|
+
piece's feed token immediately after (§14's per-page crash order).
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
frame: One fetched piece (one feed page's validated rows).
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ProviderResponseError: A row's event-time value is null — a
|
|
130
|
+
feed record without its partition key is a provider
|
|
131
|
+
contract violation surfaced loudly BEFORE any part lands
|
|
132
|
+
(never a mid-write stall behind an untyped error).
|
|
133
|
+
ValueError: A foreign ``part-*.parquet`` file skews a partition's
|
|
134
|
+
numbering (from the scan).
|
|
135
|
+
RuntimeError: The scanned next part number already exists on disk
|
|
136
|
+
— the single-writer assumption is violated; refusing keeps the
|
|
137
|
+
append-only invariant (I3) intact instead of clobbering a
|
|
138
|
+
landed part.
|
|
139
|
+
|
|
140
|
+
Side Effects:
|
|
141
|
+
Writes one new part file per event date present, creating
|
|
142
|
+
partition directories as needed. Never modifies an existing file.
|
|
143
|
+
"""
|
|
144
|
+
null_dated = frame[self._event_time_column].null_count() if frame.height else 0
|
|
145
|
+
if null_dated:
|
|
146
|
+
raise ProviderResponseError(
|
|
147
|
+
detail=(
|
|
148
|
+
f'{null_dated} feed record(s) carry a null '
|
|
149
|
+
f'{self._event_time_column!r} -- the append-log partition '
|
|
150
|
+
f'key; refusing the page whole before any part lands'
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
for partition_date, sub_frame in split_by_date(frame, self._event_time_column):
|
|
154
|
+
part_number = _next_part_number(
|
|
155
|
+
partition_dir(self._target_dir, partition_date)
|
|
156
|
+
)
|
|
157
|
+
target = append_part_file(self._target_dir, partition_date, part_number)
|
|
158
|
+
if target.exists():
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
f'append-log part collision: {target} already exists -- '
|
|
161
|
+
f'two writers on one endpoint violate the single-writer '
|
|
162
|
+
f'invariant (DESIGN section 5)'
|
|
163
|
+
)
|
|
164
|
+
atomic_write_parquet(sub_frame, target, durable=True)
|
|
165
|
+
self._rows_written += sub_frame.height
|
|
166
|
+
self._files_written += 1
|
|
167
|
+
|
|
168
|
+
def finalize(self) -> WriteResult:
|
|
169
|
+
"""Report the accumulated append counts; write nothing.
|
|
170
|
+
|
|
171
|
+
Every piece already landed durably in ``write`` (the per-page crash
|
|
172
|
+
order requires it), so there is nothing left to persist.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The write report — rows and part files appended this run,
|
|
176
|
+
``duplicates_dropped`` always ``0`` (no write-time dedup, by
|
|
177
|
+
design), ``deleted_partitions`` always empty (nothing is ever
|
|
178
|
+
deleted).
|
|
179
|
+
|
|
180
|
+
Side Effects:
|
|
181
|
+
None.
|
|
182
|
+
"""
|
|
183
|
+
return WriteResult(
|
|
184
|
+
rows_written=self._rows_written,
|
|
185
|
+
duplicates_dropped=0,
|
|
186
|
+
files_written=self._files_written,
|
|
187
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# src/fleetpull/storage/atomic.py
|
|
2
|
+
"""The atomic file writes: the storage layer's durability primitives.
|
|
3
|
+
|
|
4
|
+
Every layout's every write goes through here. A file write is not atomic -- a
|
|
5
|
+
crash mid-write corrupts the file -- so the content is written to a temp sibling
|
|
6
|
+
and renamed onto the target, which POSIX guarantees is atomic on one filesystem.
|
|
7
|
+
The prior target is untouched until the rename; a crash leaves either the old
|
|
8
|
+
file or the new, never a partial one (DESIGN §5 crash-safety). The temp is
|
|
9
|
+
always cleaned up: on success (already renamed away) or on failure.
|
|
10
|
+
``atomic_write_parquet`` is the parquet write every dataset writer uses;
|
|
11
|
+
``atomic_write_text`` is the same temp-then-rename skeleton for the
|
|
12
|
+
``metadata.json`` projection's document text.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import polars as pl
|
|
19
|
+
|
|
20
|
+
from fleetpull.polars_typing import ParquetCompression
|
|
21
|
+
from fleetpull.storage.files import temp_sibling_path
|
|
22
|
+
|
|
23
|
+
__all__: list[str] = ['atomic_write_parquet', 'atomic_write_text']
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fsync_path(path: Path) -> None:
|
|
27
|
+
"""Flush one path's kernel buffers to stable storage.
|
|
28
|
+
|
|
29
|
+
The open/fsync/close block the durable-rename recipe applies to the
|
|
30
|
+
temp file before the rename and to each directory of the durable
|
|
31
|
+
chain after it.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: The file or directory to fsync.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
OSError: The open or fsync failed.
|
|
38
|
+
|
|
39
|
+
Side Effects:
|
|
40
|
+
Issues an ``fsync`` system call.
|
|
41
|
+
"""
|
|
42
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
43
|
+
try:
|
|
44
|
+
os.fsync(descriptor)
|
|
45
|
+
finally:
|
|
46
|
+
os.close(descriptor)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _durable_directory_chain(directory: Path) -> list[Path]:
|
|
50
|
+
"""The directories a durable write must fsync after its rename.
|
|
51
|
+
|
|
52
|
+
Always ``directory`` itself -- the rename lands the file's entry
|
|
53
|
+
there. When the write is about to CREATE missing ancestors (a feed
|
|
54
|
+
page opening a new ``date=`` partition, or the first-ever write of an
|
|
55
|
+
endpoint), each new directory's own entry lives one level up, so the
|
|
56
|
+
chain extends through every missing ancestor to the first
|
|
57
|
+
pre-existing one: a new-directory chain fsynced only at its deepest
|
|
58
|
+
link is NOT durable -- power loss can drop the newly created
|
|
59
|
+
directory (and the file inside it) from the un-fsynced parent while
|
|
60
|
+
a later fsynced commit survives, persisting a cursor past lost data.
|
|
61
|
+
|
|
62
|
+
Must be computed BEFORE the ``mkdir`` that creates the chain, while
|
|
63
|
+
the missing set is still observable.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
directory: The file's parent directory, existing or about to be
|
|
67
|
+
created.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
``directory`` first, then each missing ancestor's parent up to
|
|
71
|
+
and including the first pre-existing directory. Just
|
|
72
|
+
``[directory]`` when it already exists.
|
|
73
|
+
"""
|
|
74
|
+
chain = [directory]
|
|
75
|
+
current = directory
|
|
76
|
+
while not current.exists():
|
|
77
|
+
chain.append(current.parent)
|
|
78
|
+
current = current.parent
|
|
79
|
+
return chain
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def atomic_write_parquet(
|
|
83
|
+
frame: pl.DataFrame,
|
|
84
|
+
target: Path,
|
|
85
|
+
compression: ParquetCompression = 'snappy',
|
|
86
|
+
*,
|
|
87
|
+
durable: bool = False,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Write ``frame`` to ``target`` atomically via temp-then-rename.
|
|
90
|
+
|
|
91
|
+
Ensures ``target``'s parent directory exists, writes the frame to a temp
|
|
92
|
+
sibling, then atomically renames it onto ``target``. Never leaves a partial
|
|
93
|
+
``target``.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
frame: The DataFrame to persist.
|
|
97
|
+
target: The final parquet path.
|
|
98
|
+
compression: Parquet compression codec. ``'snappy'`` by default (fast,
|
|
99
|
+
BigQuery-friendly); a later config surface parameterizes it.
|
|
100
|
+
durable: When True, fsync the temp file before the rename and the
|
|
101
|
+
durable directory chain after it -- the parent, every ancestor
|
|
102
|
+
this write newly created, and the first pre-existing ancestor
|
|
103
|
+
(whose entry for the new chain must also reach stable
|
|
104
|
+
storage). The feed append cell requires it: its token commit
|
|
105
|
+
is fsynced (SQLite), so a power loss must never persist a
|
|
106
|
+
token whose page -- or whose newly created partition
|
|
107
|
+
directory -- the page cache still held. The self-healing
|
|
108
|
+
writers (replace-partition under lookback refetch, snapshot
|
|
109
|
+
rewrite) stay non-durable -- a lost write there is refetched
|
|
110
|
+
by design.
|
|
111
|
+
|
|
112
|
+
Side Effects:
|
|
113
|
+
Creates ``target``'s parent directory; writes and renames files;
|
|
114
|
+
when ``durable``, fsyncs the file and the directory chain.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
OSError: If the write, rename, or fsync fails (the temp is cleaned
|
|
118
|
+
up first).
|
|
119
|
+
"""
|
|
120
|
+
durable_chain = _durable_directory_chain(target.parent) if durable else []
|
|
121
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
temp: Path = temp_sibling_path(target)
|
|
123
|
+
try:
|
|
124
|
+
frame.write_parquet(temp, compression=compression)
|
|
125
|
+
if durable:
|
|
126
|
+
_fsync_path(temp)
|
|
127
|
+
temp.replace(target)
|
|
128
|
+
for directory in durable_chain:
|
|
129
|
+
_fsync_path(directory)
|
|
130
|
+
finally:
|
|
131
|
+
temp.unlink(missing_ok=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def atomic_write_text(text: str, target: Path) -> None:
|
|
135
|
+
"""Write ``text`` to ``target`` atomically via temp-then-rename.
|
|
136
|
+
|
|
137
|
+
The parquet write's temp-then-rename skeleton applied to document text:
|
|
138
|
+
temp-sibling in the target's own directory, so the rename is
|
|
139
|
+
same-filesystem and atomic; a crash leaves the prior file or the new one,
|
|
140
|
+
never a partial. The target's parent directory is deliberately NOT
|
|
141
|
+
created -- the one caller (the ``metadata.json`` projection) requires an
|
|
142
|
+
absent endpoint directory to surface as ``OSError``, never be papered
|
|
143
|
+
over with a ``mkdir``.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
text: The complete document text to persist, written UTF-8.
|
|
147
|
+
target: The final file path; its parent directory must exist.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
OSError: The write or rename failed -- including a missing parent
|
|
151
|
+
directory (the temp is cleaned up first).
|
|
152
|
+
|
|
153
|
+
Side Effects:
|
|
154
|
+
Writes and renames files inside ``target``'s directory.
|
|
155
|
+
"""
|
|
156
|
+
temp: Path = temp_sibling_path(target)
|
|
157
|
+
try:
|
|
158
|
+
temp.write_text(text, encoding='utf-8')
|
|
159
|
+
temp.replace(target)
|
|
160
|
+
finally:
|
|
161
|
+
temp.unlink(missing_ok=True)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# src/fleetpull/storage/files.py
|
|
2
|
+
"""Storage file-path construction: the parquet-format-specific paths under an
|
|
3
|
+
endpoint directory.
|
|
4
|
+
|
|
5
|
+
Pure path arithmetic, no filesystem access. The single-layout data file, the
|
|
6
|
+
date-partitioned layout's partition / part / staging paths, and the temp sibling
|
|
7
|
+
used for atomic writes live here -- storage-specific, unlike the shared
|
|
8
|
+
endpoint-directory construction in ``paths``. The temp sibling sits in the
|
|
9
|
+
target's own directory so the rename that follows is same-filesystem and
|
|
10
|
+
therefore atomic.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from datetime import date
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from fleetpull.paths import date_partition_segment
|
|
18
|
+
|
|
19
|
+
__all__: list[str] = [
|
|
20
|
+
'append_part_file',
|
|
21
|
+
'data_file',
|
|
22
|
+
'partition_dir',
|
|
23
|
+
'partition_part_file',
|
|
24
|
+
'partition_staging_dir',
|
|
25
|
+
'partition_staging_shard',
|
|
26
|
+
'temp_sibling_path',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# The single-layout data file name (DESIGN §3).
|
|
30
|
+
_SINGLE_FILE_NAME: str = 'data.parquet'
|
|
31
|
+
|
|
32
|
+
# The date-partitioned layout's per-partition part file name (DESIGN §3).
|
|
33
|
+
_PART_FILE_NAME: str = 'part.parquet'
|
|
34
|
+
|
|
35
|
+
# The per-partition staging directory and shard suffix (DESIGN §3).
|
|
36
|
+
_STAGING_DIR_NAME: str = 'staging'
|
|
37
|
+
|
|
38
|
+
# The append-log layout's numbered part-file width (DESIGN §3): five digits
|
|
39
|
+
# zero-padded, so directory listings sort chronologically at a glance. Purely
|
|
40
|
+
# cosmetic — the append scan parses the number, never sorts the text — so a
|
|
41
|
+
# partition beyond 99,999 parts widens naturally without breaking anything.
|
|
42
|
+
_APPEND_PART_NUMBER_WIDTH: int = 5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def data_file(endpoint_dir: Path) -> Path:
|
|
46
|
+
"""The ``single``-layout data file under an endpoint directory.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
endpoint_dir: The endpoint directory (from ``endpoint_directory``).
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
``{endpoint_dir}/data.parquet``.
|
|
53
|
+
"""
|
|
54
|
+
return endpoint_dir / _SINGLE_FILE_NAME
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def partition_dir(endpoint_dir: Path, partition_date: date) -> Path:
|
|
58
|
+
"""The date-partition directory for one date under an endpoint directory.
|
|
59
|
+
|
|
60
|
+
The single place the hive ``date=YYYY-MM-DD`` directory path is built;
|
|
61
|
+
``partition_part_file`` and the prune step both go through it, so the
|
|
62
|
+
structural fact lives once.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
endpoint_dir: The endpoint directory (from ``endpoint_directory``).
|
|
66
|
+
partition_date: The partition's calendar date.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
``{endpoint_dir}/date=YYYY-MM-DD``.
|
|
70
|
+
|
|
71
|
+
Side Effects:
|
|
72
|
+
None -- pure path arithmetic; no filesystem access.
|
|
73
|
+
"""
|
|
74
|
+
return endpoint_dir / date_partition_segment(partition_date)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def partition_part_file(endpoint_dir: Path, partition_date: date) -> Path:
|
|
78
|
+
"""The date-partitioned part file for one date under an endpoint directory.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
endpoint_dir: The endpoint directory (from ``endpoint_directory``).
|
|
82
|
+
partition_date: The partition's calendar date.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
``{endpoint_dir}/date=YYYY-MM-DD/part.parquet``.
|
|
86
|
+
|
|
87
|
+
Side Effects:
|
|
88
|
+
None -- pure path arithmetic; no filesystem access.
|
|
89
|
+
"""
|
|
90
|
+
return partition_dir(endpoint_dir, partition_date) / _PART_FILE_NAME
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def append_part_file(
|
|
94
|
+
endpoint_dir: Path, partition_date: date, part_number: int
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""The append-log layout's numbered part file for one date partition.
|
|
97
|
+
|
|
98
|
+
The path half of the append-only feed cell (DESIGN §3/§4): each feed page
|
|
99
|
+
lands as the next-numbered ``part-NNNNN.parquet`` in the partitions its
|
|
100
|
+
records' event dates route to; the numbering scan lives with the append
|
|
101
|
+
writer (``storage/append.py``), keeping this module pure path arithmetic.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
endpoint_dir: The endpoint directory (from ``endpoint_directory``).
|
|
105
|
+
partition_date: The partition's calendar date.
|
|
106
|
+
part_number: The part's ordinal (>= 1; the writer's scan supplies
|
|
107
|
+
max-existing + 1).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
``{endpoint_dir}/date=YYYY-MM-DD/part-NNNNN.parquet``.
|
|
111
|
+
|
|
112
|
+
Side Effects:
|
|
113
|
+
None -- pure path arithmetic; no filesystem access.
|
|
114
|
+
"""
|
|
115
|
+
return partition_dir(endpoint_dir, partition_date) / (
|
|
116
|
+
f'part-{part_number:0{_APPEND_PART_NUMBER_WIDTH}d}.parquet'
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def partition_staging_dir(endpoint_dir: Path, partition_date: date) -> Path:
|
|
121
|
+
"""The staging directory inside one date partition.
|
|
122
|
+
|
|
123
|
+
Holds the per-piece ``.shard`` files a fanned-out write lands before
|
|
124
|
+
compaction folds them into ``part.parquet``. Inside the partition directory
|
|
125
|
+
so staging is co-located with the partition it feeds; the ``.shard`` extension
|
|
126
|
+
on the shards (not ``.parquet``) keeps them out of a hive ``*.parquet`` read.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
endpoint_dir: The endpoint directory (from ``endpoint_directory``).
|
|
130
|
+
partition_date: The partition's calendar date.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
``{endpoint_dir}/date=YYYY-MM-DD/staging``.
|
|
134
|
+
|
|
135
|
+
Side Effects:
|
|
136
|
+
None -- pure path arithmetic; no filesystem access.
|
|
137
|
+
"""
|
|
138
|
+
return partition_dir(endpoint_dir, partition_date) / _STAGING_DIR_NAME
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def partition_staging_shard(endpoint_dir: Path, partition_date: date) -> Path:
|
|
142
|
+
"""A unique ``.shard`` path under one date partition's staging directory.
|
|
143
|
+
|
|
144
|
+
Each call returns a fresh uuid-named shard, so the fanned-out writes to one
|
|
145
|
+
date never collide. The ``.shard`` extension keeps the file out of a hive
|
|
146
|
+
``*.parquet`` read; compaction reads it back by explicit path.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
endpoint_dir: The endpoint directory (from ``endpoint_directory``).
|
|
150
|
+
partition_date: The partition's calendar date.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
``{endpoint_dir}/date=YYYY-MM-DD/staging/shard-{uuid}.shard``.
|
|
154
|
+
|
|
155
|
+
Side Effects:
|
|
156
|
+
None -- pure path construction (the uuid makes each call unique).
|
|
157
|
+
"""
|
|
158
|
+
return partition_staging_dir(endpoint_dir, partition_date) / (
|
|
159
|
+
f'shard-{uuid4().hex}.shard'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def temp_sibling_path(target: Path) -> Path:
|
|
164
|
+
"""A unique temporary path beside ``target`` for an atomic write.
|
|
165
|
+
|
|
166
|
+
Placed in ``target``'s own directory so the follow-up rename stays on one
|
|
167
|
+
filesystem (POSIX guarantees same-filesystem rename atomicity). The unique
|
|
168
|
+
suffix avoids colliding with a stale temp from an earlier interrupted write.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
target: The final file the temp will be renamed onto.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A hidden, uniquely-named sibling temp path.
|
|
175
|
+
"""
|
|
176
|
+
return target.with_name(f'.{target.name}.{uuid4().hex}.tmp')
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# src/fleetpull/storage/frames.py
|
|
2
|
+
"""Frame operations the write path composes: the exact-duplicate dedup and the
|
|
3
|
+
half-open window-membership predicate.
|
|
4
|
+
|
|
5
|
+
Pure DataFrame helpers. ``drop_exact_duplicates`` is the write-time exact
|
|
6
|
+
dedup (DESIGN §6), and ``dedup_counting`` is the flag-and-count composition
|
|
7
|
+
the replace-partition and single-file writers run on
|
|
8
|
+
their finalized frames, clearing byte-identical rows as a cheap safety net.
|
|
9
|
+
The feed append cell deliberately does NOT (DESIGN §4's stored-as-emitted
|
|
10
|
+
contract and §14's append-only invariant): crash-window and re-emission
|
|
11
|
+
duplicates are the log's honest content, reconciled by the consumer. ``in_window`` is the half-open ``[start, end)``
|
|
12
|
+
membership predicate that defines the window boundary in exactly one place: its
|
|
13
|
+
consumer today is the orchestrator's batch shaping, which keeps a fetch's
|
|
14
|
+
in-window rows before they reach a writer; the future single-file window-clearing
|
|
15
|
+
cells will apply it in both polarities (delete the existing window's rows, keep
|
|
16
|
+
the fresh fetch's in-window rows) from the same rule.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import polars as pl
|
|
20
|
+
|
|
21
|
+
from fleetpull.incremental import DateWindow
|
|
22
|
+
|
|
23
|
+
__all__: list[str] = ['dedup_counting', 'drop_exact_duplicates', 'in_window']
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dedup_counting(frame: pl.DataFrame, *, enabled: bool) -> tuple[pl.DataFrame, int]:
|
|
27
|
+
"""Apply the write-time exact dedup when enabled, counting what it dropped.
|
|
28
|
+
|
|
29
|
+
The dedup-and-count step every deduping write path runs before
|
|
30
|
+
persisting -- the finalized single file and each compacted partition --
|
|
31
|
+
stated once so the flag handling and the dropped-row arithmetic never
|
|
32
|
+
drift between writers.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
frame: The frame about to be written.
|
|
36
|
+
enabled: The ``storage.drop_exact_duplicates`` switch; ``False``
|
|
37
|
+
passes ``frame`` through byte-for-byte.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The frame to write and the count of exact-duplicate rows removed
|
|
41
|
+
(``0`` when disabled or none were found).
|
|
42
|
+
"""
|
|
43
|
+
if not enabled:
|
|
44
|
+
return frame, 0
|
|
45
|
+
written = drop_exact_duplicates(frame)
|
|
46
|
+
return written, frame.height - written.height
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def drop_exact_duplicates(frame: pl.DataFrame) -> pl.DataFrame:
|
|
50
|
+
"""Drop byte-identical duplicate rows, preserving first-occurrence order.
|
|
51
|
+
|
|
52
|
+
Exactness over all columns: two rows collapse only if every value matches.
|
|
53
|
+
Same-key-different-payload rows are deliberately kept -- collapsing those is
|
|
54
|
+
semantic dedup, out of scope (DESIGN §6).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
frame: The frame to dedup.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The frame with exact-duplicate rows removed, order preserved.
|
|
61
|
+
"""
|
|
62
|
+
return frame.unique(maintain_order=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def in_window(event_time_column: str, window: DateWindow) -> pl.Expr:
|
|
66
|
+
"""The half-open ``[start, end)`` window-membership predicate for a column.
|
|
67
|
+
|
|
68
|
+
Returns the boolean Polars expression true for rows whose
|
|
69
|
+
``event_time_column`` falls in ``window`` -- ``>= window.start`` and
|
|
70
|
+
``< window.end``, the half-open boundary made literal. It is a *predicate*,
|
|
71
|
+
not a filter, so the boundary is defined in exactly one place and "removal"
|
|
72
|
+
stays the caller's concern (DESIGN §4). Its consumer today is the
|
|
73
|
+
orchestrator's batch shaping, which keeps a fetch's in-window rows with
|
|
74
|
+
``frame.filter(in_window(col, w))``; the future single-file window-clearing
|
|
75
|
+
cells will add the other polarity, ``frame.filter(~in_window(col, w))``, to
|
|
76
|
+
delete a window's rows from the existing on-disk frame.
|
|
77
|
+
|
|
78
|
+
The full ``[start, end)`` predicate, not merely ``>= start``: in steady state
|
|
79
|
+
``end`` is ``now`` and ``< end`` binds nothing, but a historical backfill
|
|
80
|
+
chunk has a real ``end``, and ``< end`` is what stops an event on a chunk
|
|
81
|
+
boundary from being claimed by both the chunk ending at it and the one
|
|
82
|
+
starting at it. ``window`` already guarantees ``start < end`` at construction,
|
|
83
|
+
so the predicate never sees an inverted range.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
event_time_column: Name of the UTC datetime column to test.
|
|
87
|
+
window: The half-open ``[start, end)`` resume window.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
A boolean ``pl.Expr`` true for in-window rows; apply it (or its negation)
|
|
91
|
+
with ``DataFrame.filter``.
|
|
92
|
+
|
|
93
|
+
Side Effects:
|
|
94
|
+
None -- builds an expression; evaluates nothing.
|
|
95
|
+
"""
|
|
96
|
+
return (pl.col(event_time_column) >= window.start) & (
|
|
97
|
+
pl.col(event_time_column) < window.end
|
|
98
|
+
)
|