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,127 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/backfill.py
|
|
2
|
+
"""Window decomposition: a windowed run's range into per-chunk work units.
|
|
3
|
+
|
|
4
|
+
The caller-side planning the work-unit store defers to its driver (DESIGN §5).
|
|
5
|
+
Every windowed run -- the daily pull and a long backfill alike -- tiles its
|
|
6
|
+
(provider, endpoint) window into whole-UTC-day chunks, one unit per chunk (a
|
|
7
|
+
window smaller than one chunk is a single unit). A unit fans the whole roster
|
|
8
|
+
at execution, so it carries no member -- one planner serves fan-out and
|
|
9
|
+
non-fan-out watermark endpoints alike, the fan-out distinction being the
|
|
10
|
+
driver's, not the unit's. Pure functions only: the runner resolves the window,
|
|
11
|
+
calls these to plan, and drives the queue.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
|
|
16
|
+
from fleetpull.incremental import DateWindow
|
|
17
|
+
from fleetpull.state import WorkUnitSpec
|
|
18
|
+
from fleetpull.vocabulary import Provider
|
|
19
|
+
|
|
20
|
+
__all__: list[str] = ['plan_backfill_units']
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_utc_midnight(value: datetime) -> bool:
|
|
24
|
+
"""True when ``value`` is exactly midnight UTC -- a whole-UTC-day boundary.
|
|
25
|
+
|
|
26
|
+
A date-partition boundary: a zero UTC offset and a zeroed time of day. Naive
|
|
27
|
+
and non-UTC datetimes are rejected (``utcoffset`` is ``None`` or nonzero), so
|
|
28
|
+
this also gates the timezone validity ``DateWindow`` does not.
|
|
29
|
+
"""
|
|
30
|
+
return (
|
|
31
|
+
value.utcoffset() == timedelta(0)
|
|
32
|
+
and value.hour == 0
|
|
33
|
+
and value.minute == 0
|
|
34
|
+
and value.second == 0
|
|
35
|
+
and value.microsecond == 0
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _date_chunks(span: DateWindow, chunk: timedelta) -> list[tuple[datetime, datetime]]:
|
|
40
|
+
"""Tile a half-open span into contiguous whole-UTC-day chunks.
|
|
41
|
+
|
|
42
|
+
Splits ``[span.start, span.end)`` into half-open chunks of ``chunk`` width,
|
|
43
|
+
left to right; the final chunk runs to ``span.end`` and so may be shorter
|
|
44
|
+
(but still a whole number of days). A chunk is emitted only while the cursor
|
|
45
|
+
is strictly before ``span.end``, so no zero-width chunk is produced and every
|
|
46
|
+
bound pair satisfies the work-unit store's ``chunk_start < chunk_end``.
|
|
47
|
+
|
|
48
|
+
Both span bounds must be midnight UTC and ``chunk`` a positive whole number of
|
|
49
|
+
days, so every chunk bound lands on midnight UTC. The date-partitioned
|
|
50
|
+
watermark writer replaces whole date partitions, so a chunk boundary mid-day
|
|
51
|
+
would drive partial-day replacement and silently corrupt the partitions it
|
|
52
|
+
touches -- hence the guards rather than a permissive ``timedelta``.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
span: The half-open range to tile; both bounds midnight UTC.
|
|
56
|
+
chunk: The width of each chunk; a positive whole number of days.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The chunk bounds in order, each ``(chunk_start, chunk_end)`` on midnight
|
|
60
|
+
UTC with ``chunk_start < chunk_end``; contiguous (each chunk's end is the
|
|
61
|
+
next's start), the first at ``span.start`` and the last at ``span.end``.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: When ``chunk`` is not a positive whole number of days, or a
|
|
65
|
+
span bound is not midnight UTC -- caller bugs, kept stdlib.
|
|
66
|
+
"""
|
|
67
|
+
if chunk <= timedelta(0) or chunk % timedelta(days=1) != timedelta(0):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f'backfill chunk must be a positive whole number of days: {chunk!r}'
|
|
70
|
+
)
|
|
71
|
+
if not _is_utc_midnight(span.start) or not _is_utc_midnight(span.end):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
'backfill chunks require whole-UTC-day span bounds (midnight UTC): '
|
|
74
|
+
f'start={span.start!r}, end={span.end!r}'
|
|
75
|
+
)
|
|
76
|
+
chunks: list[tuple[datetime, datetime]] = []
|
|
77
|
+
start = span.start
|
|
78
|
+
while start < span.end:
|
|
79
|
+
end = min(start + chunk, span.end)
|
|
80
|
+
chunks.append((start, end))
|
|
81
|
+
start = end
|
|
82
|
+
return chunks
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def plan_backfill_units(
|
|
86
|
+
provider: Provider,
|
|
87
|
+
endpoint: str,
|
|
88
|
+
span: DateWindow,
|
|
89
|
+
chunk: timedelta,
|
|
90
|
+
) -> list[WorkUnitSpec]:
|
|
91
|
+
"""Decompose a backfill into one work unit per whole-UTC-day chunk.
|
|
92
|
+
|
|
93
|
+
The caller-side decomposition the work-unit store leaves to its driver
|
|
94
|
+
(DESIGN §5): tile the span into whole-UTC-day chunks, one unit per chunk. A
|
|
95
|
+
unit fans the whole roster at execution, so it carries no member
|
|
96
|
+
(``partition_key=None``); the unit loop drives each chunk through the
|
|
97
|
+
endpoint's already-resolved driver. So one planner serves fan-out and
|
|
98
|
+
non-fan-out watermark endpoints alike; the fan-out distinction is the
|
|
99
|
+
driver's, not the unit's. Pure: it returns the specs; enqueuing them
|
|
100
|
+
idempotently is the caller's, kept separate so the plan can be inspected and
|
|
101
|
+
the enqueue stays the store's one write.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
provider: The provider being backfilled.
|
|
105
|
+
endpoint: The endpoint being backfilled.
|
|
106
|
+
span: The full backfill range, half-open and midnight-UTC on both bounds
|
|
107
|
+
(the runner's residual-window resolution builds it from the resume
|
|
108
|
+
precedence against the trailing edge).
|
|
109
|
+
chunk: The width of each date chunk; a positive whole number of days.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
One ``WorkUnitSpec`` per chunk, in chronological order, ready to enqueue.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: When the span bounds are not midnight UTC or ``chunk`` is not a
|
|
116
|
+
positive whole number of days (from :func:`_date_chunks`).
|
|
117
|
+
"""
|
|
118
|
+
return [
|
|
119
|
+
WorkUnitSpec(
|
|
120
|
+
provider=provider,
|
|
121
|
+
endpoint=endpoint,
|
|
122
|
+
partition_key=None,
|
|
123
|
+
chunk_start=chunk_start,
|
|
124
|
+
chunk_end=chunk_end,
|
|
125
|
+
)
|
|
126
|
+
for chunk_start, chunk_end in _date_chunks(span, chunk)
|
|
127
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/batch.py
|
|
2
|
+
"""Per-batch transform: validate and frame one record batch, and (watermark only)
|
|
3
|
+
guard, window-filter, and fold its event time.
|
|
4
|
+
|
|
5
|
+
``process_batch`` is the shared per-batch step both run-executor arms drive: the
|
|
6
|
+
snapshot arm passes ``context=None`` (validate + frame only); the watermark arm
|
|
7
|
+
passes a ``WindowContext`` to additionally apply the future-event guard, filter the
|
|
8
|
+
frame to the resume window, and produce the batch's in-window fold candidate.
|
|
9
|
+
``combine_latest_event_time`` folds those candidates across batches. Pure
|
|
10
|
+
transforms -- the caller writes the frame and commits the watermark (DESIGN §14).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
import polars as pl
|
|
17
|
+
|
|
18
|
+
from fleetpull.endpoints.shared import EndpointDefinition
|
|
19
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
20
|
+
from fleetpull.incremental import DateWindow
|
|
21
|
+
from fleetpull.model_contract import ResponseModel
|
|
22
|
+
from fleetpull.records import latest_event_time, models_to_dataframe, validate_records
|
|
23
|
+
from fleetpull.storage import in_window
|
|
24
|
+
from fleetpull.vocabulary import JsonObject
|
|
25
|
+
|
|
26
|
+
__all__: list[str] = [
|
|
27
|
+
'ProcessedBatch',
|
|
28
|
+
'WindowContext',
|
|
29
|
+
'combine_latest_event_time',
|
|
30
|
+
'process_batch',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class WindowContext:
|
|
36
|
+
"""The watermark arm's per-batch context: window, clock, and event column.
|
|
37
|
+
|
|
38
|
+
Present only on the watermark path; ``process_batch`` takes
|
|
39
|
+
``WindowContext | None`` and treats ``None`` as the snapshot path (no
|
|
40
|
+
guard, no window filter, no fold candidate).
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
window: The resolved half-open ``[start, end)`` resume window.
|
|
44
|
+
now: The run's clock instant, for the future-event guard.
|
|
45
|
+
event_time_column: The response model's UTC datetime field the window
|
|
46
|
+
filter and fold read.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
window: DateWindow
|
|
50
|
+
now: datetime
|
|
51
|
+
event_time_column: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ProcessedBatch:
|
|
56
|
+
"""One processed batch: the frame to write and its fold candidate.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
frame: The frame to hand to ``writer.write`` -- the validated, framed
|
|
60
|
+
batch on the snapshot path; that frame filtered to the resume
|
|
61
|
+
window on the watermark path. Its ``height`` is the run's per-batch
|
|
62
|
+
row count (one row per validated model; the ledger counts the rows
|
|
63
|
+
written for the window).
|
|
64
|
+
latest_event_time: The maximum in-window event time in this batch, or
|
|
65
|
+
``None`` (snapshot path, or an empty/all-filtered batch). The
|
|
66
|
+
cross-batch fold input.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
frame: pl.DataFrame
|
|
70
|
+
latest_event_time: datetime | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def process_batch(
|
|
74
|
+
batch: list[JsonObject],
|
|
75
|
+
definition: EndpointDefinition[ResponseModel],
|
|
76
|
+
context: WindowContext | None,
|
|
77
|
+
) -> ProcessedBatch:
|
|
78
|
+
"""Validate, frame, and (watermark only) guard-and-window one batch.
|
|
79
|
+
|
|
80
|
+
The shared per-batch transform both runner arms drive. Snapshot path
|
|
81
|
+
(``context is None``): validate the raw records against the response model
|
|
82
|
+
and frame them; the frame is written as-is and carries no fold candidate.
|
|
83
|
+
Watermark path: additionally apply the future-event guard to the raw frame,
|
|
84
|
+
filter the frame to the resume window, and fold the in-window maximum event
|
|
85
|
+
time.
|
|
86
|
+
|
|
87
|
+
The guard runs on the *raw* frame, before the window filter: the window's
|
|
88
|
+
end is at or before ``now`` (the trailing edge is held back), so a
|
|
89
|
+
future-dated record falls outside the window and the filter would silently
|
|
90
|
+
drop it -- guarding the raw frame surfaces the anomaly instead. The fold
|
|
91
|
+
uses the *filtered* frame: an event time past ``window.end`` would otherwise
|
|
92
|
+
advance the watermark past the trailing edge and skip the next run's cutoff
|
|
93
|
+
holdback.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
batch: One batch of raw response records from the driver.
|
|
97
|
+
definition: The endpoint binding (response model for validation).
|
|
98
|
+
context: The watermark per-batch context, or ``None`` for snapshot.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The frame to write and its fold candidate.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ProviderResponseError: A raw event time exceeds ``context.now`` -- a
|
|
105
|
+
contract violation (watermark path only). Validation and framing
|
|
106
|
+
errors propagate from ``validate_records`` / ``models_to_dataframe``
|
|
107
|
+
unchanged.
|
|
108
|
+
|
|
109
|
+
Side Effects:
|
|
110
|
+
None -- pure transform; the caller writes the frame.
|
|
111
|
+
"""
|
|
112
|
+
models = validate_records(batch, definition.response_model)
|
|
113
|
+
frame = models_to_dataframe(models, definition.response_model)
|
|
114
|
+
if context is None:
|
|
115
|
+
return ProcessedBatch(frame=frame, latest_event_time=None)
|
|
116
|
+
observed_raw = latest_event_time(frame, context.event_time_column)
|
|
117
|
+
if observed_raw is not None and observed_raw > context.now:
|
|
118
|
+
raise ProviderResponseError(
|
|
119
|
+
provider=definition.provider.value,
|
|
120
|
+
endpoint=definition.name,
|
|
121
|
+
detail=(
|
|
122
|
+
f'observed event time {observed_raw.isoformat()} is after the '
|
|
123
|
+
f'run clock {context.now.isoformat()}'
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
in_scope = frame.filter(in_window(context.event_time_column, context.window))
|
|
127
|
+
return ProcessedBatch(
|
|
128
|
+
frame=in_scope,
|
|
129
|
+
latest_event_time=latest_event_time(in_scope, context.event_time_column),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def combine_latest_event_time(
|
|
134
|
+
running: datetime | None, candidate: datetime | None
|
|
135
|
+
) -> datetime | None:
|
|
136
|
+
"""Fold a batch's in-window max into the running watermark candidate.
|
|
137
|
+
|
|
138
|
+
None-tolerant: an empty or all-filtered batch contributes ``None`` and
|
|
139
|
+
leaves the running maximum unchanged.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
running: The accumulated maximum so far, or ``None``.
|
|
143
|
+
candidate: This batch's in-window maximum, or ``None``.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The greater of the two, or whichever is non-``None``, or ``None``.
|
|
147
|
+
|
|
148
|
+
Side Effects:
|
|
149
|
+
None.
|
|
150
|
+
"""
|
|
151
|
+
if running is None:
|
|
152
|
+
return candidate
|
|
153
|
+
if candidate is None:
|
|
154
|
+
return running
|
|
155
|
+
return max(running, candidate)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/bisection.py
|
|
2
|
+
"""The bisecting request driver: complete fetches from capped, unsortable Gets.
|
|
3
|
+
|
|
4
|
+
The third ``RequestDriver`` (the request-cardinality seam,
|
|
5
|
+
``orchestrator/drivers.py``): for endpoints declaring the
|
|
6
|
+
``BisectedWindowFetch`` request shape, the unit's resume window is
|
|
7
|
+
fetched whole; a
|
|
8
|
+
response of exactly the declared ``results_limit`` is the overflow
|
|
9
|
+
signal — the page is discarded, the window halves at a whole-second
|
|
10
|
+
midpoint, and both halves recurse left-to-right; a floor-width window
|
|
11
|
+
still returning a full page raises loudly (the data is denser than
|
|
12
|
+
windowed fetching can enumerate — the provider's feed transport is the
|
|
13
|
+
escape for such streams). Overflow is a return-type condition (page
|
|
14
|
+
length), never an exception.
|
|
15
|
+
|
|
16
|
+
Fetch grain thereby decouples from write grain: work units and the
|
|
17
|
+
delete-by-window merge stay whole-window while only the wire requests
|
|
18
|
+
narrow. Every sub-request rides ``client.fetch_pages`` — one limiter
|
|
19
|
+
token per attempt, exactly like any page walk — and a mid-recursion
|
|
20
|
+
crash re-claims the whole unit, idempotent under delete-by-window
|
|
21
|
+
(no bisection state is ever persisted).
|
|
22
|
+
|
|
23
|
+
Under overlap-matched retrieval a record straddling an internal split
|
|
24
|
+
boundary is returned by both neighboring leaves, so each emitted page
|
|
25
|
+
is filtered to the records ANCHORED in its own leaf window (the
|
|
26
|
+
binding's ``event_time_wire_key``): leaves partition the unit, every
|
|
27
|
+
record has exactly one owning leaf, and write-time dedup stays hygiene
|
|
28
|
+
rather than a correctness mechanism. Midpoints are computed at whole
|
|
29
|
+
seconds because fractional-second search bounds are unprobed on the
|
|
30
|
+
wire.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from collections.abc import Iterator
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from datetime import datetime, timedelta
|
|
37
|
+
|
|
38
|
+
from fleetpull.endpoints.shared import (
|
|
39
|
+
BisectedWindowFetch,
|
|
40
|
+
EndpointDefinition,
|
|
41
|
+
ResumeValue,
|
|
42
|
+
require_date_window,
|
|
43
|
+
)
|
|
44
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
45
|
+
from fleetpull.incremental import DateWindow
|
|
46
|
+
from fleetpull.model_contract import ResponseModel
|
|
47
|
+
from fleetpull.network.client import FetchedPage, TransportClient
|
|
48
|
+
from fleetpull.vocabulary import JsonObject
|
|
49
|
+
|
|
50
|
+
__all__: list[str] = ['BisectingWindowDriver']
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class _BisectionTally:
|
|
57
|
+
"""Mutable leaf/overflow counters threaded through one unit's recursion.
|
|
58
|
+
|
|
59
|
+
Narration state only -- the fetch and filter semantics never read it.
|
|
60
|
+
``record_batches`` folds it into the unit-level INFO line so an
|
|
61
|
+
operator sees that (and how hard) bisection engaged, without a log
|
|
62
|
+
line per leaf.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
leaves: Non-overflowing windows fetched and yielded.
|
|
66
|
+
overflows: Windows that returned exactly ``results_limit`` records
|
|
67
|
+
and were halved.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
leaves: int = 0
|
|
71
|
+
overflows: int = 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class BisectingWindowDriver:
|
|
76
|
+
"""Fetch a windowed endpoint completely by adaptive window bisection.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
shape: The endpoint's declared ``BisectedWindowFetch`` facts — the
|
|
80
|
+
overflow threshold, the floor width, and the wire key that
|
|
81
|
+
anchors each record to its one owning leaf window.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
shape: BisectedWindowFetch
|
|
85
|
+
|
|
86
|
+
def record_batches(
|
|
87
|
+
self,
|
|
88
|
+
definition: EndpointDefinition[ResponseModel],
|
|
89
|
+
client: TransportClient,
|
|
90
|
+
resume: ResumeValue,
|
|
91
|
+
) -> Iterator[FetchedPage]:
|
|
92
|
+
"""Yield one ownership-filtered batch per non-overflowing leaf window.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
definition: The endpoint being run (its ``spec_builder``,
|
|
96
|
+
``page_decoder``, and ``quota_scope``).
|
|
97
|
+
client: The transport client for this endpoint's provider.
|
|
98
|
+
resume: The run's resume window. Must be a ``DateWindow`` — a
|
|
99
|
+
watermark endpoint always resumes from one; any other
|
|
100
|
+
value is a wiring bug.
|
|
101
|
+
|
|
102
|
+
Yields:
|
|
103
|
+
One ``FetchedPage`` per leaf window, left-to-right, each
|
|
104
|
+
holding only the records anchored in that leaf.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
TypeError: ``resume`` is not a ``DateWindow``.
|
|
108
|
+
ProviderResponseError: A floor-width window still returned a
|
|
109
|
+
full page (the loud no-narrower failure), or a record
|
|
110
|
+
arrived without a parseable anchor timestamp.
|
|
111
|
+
"""
|
|
112
|
+
window = require_date_window(resume, type(self).__name__)
|
|
113
|
+
tally = _BisectionTally()
|
|
114
|
+
yield from self._drive_window(definition, client, window, tally)
|
|
115
|
+
if tally.overflows:
|
|
116
|
+
logger.info(
|
|
117
|
+
'bisection complete: provider=%s endpoint=%s window_start=%s '
|
|
118
|
+
'window_end=%s leaves=%d overflows=%d',
|
|
119
|
+
definition.provider.value,
|
|
120
|
+
definition.name,
|
|
121
|
+
window.start.isoformat(),
|
|
122
|
+
window.end.isoformat(),
|
|
123
|
+
tally.leaves,
|
|
124
|
+
tally.overflows,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _drive_window(
|
|
128
|
+
self,
|
|
129
|
+
definition: EndpointDefinition[ResponseModel],
|
|
130
|
+
client: TransportClient,
|
|
131
|
+
window: DateWindow,
|
|
132
|
+
tally: _BisectionTally,
|
|
133
|
+
) -> Iterator[FetchedPage]:
|
|
134
|
+
"""Fetch one window; recurse on overflow, yield the leaf otherwise.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
definition: The endpoint being run.
|
|
138
|
+
client: The transport client for this endpoint's provider.
|
|
139
|
+
window: The window to fetch — the unit's resume window at the
|
|
140
|
+
top of the recursion, a half of a parent below it.
|
|
141
|
+
tally: The recursion's shared leaf/overflow counters, folded
|
|
142
|
+
into the unit-level narration by ``record_batches``.
|
|
143
|
+
|
|
144
|
+
Yields:
|
|
145
|
+
The leaf batches under this window, left-to-right.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ProviderResponseError: Per ``record_batches``.
|
|
149
|
+
"""
|
|
150
|
+
spec = definition.spec_builder.build_spec(resume=window, member_values={})
|
|
151
|
+
pages = list(
|
|
152
|
+
client.fetch_pages(
|
|
153
|
+
spec, definition.page_decoder, definition.quota_scope.value
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
# The endpoint's decoder is single-page (terminal on the first
|
|
157
|
+
# page), so the chain is exactly one page.
|
|
158
|
+
records = [record for page in pages for record in page.records]
|
|
159
|
+
if len(records) < self.shape.results_limit:
|
|
160
|
+
tally.leaves += 1
|
|
161
|
+
yield FetchedPage(
|
|
162
|
+
records=self._anchored_in(records, window, definition),
|
|
163
|
+
durable_progress=None,
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
width = window.end - window.start
|
|
167
|
+
if width <= self.shape.floor:
|
|
168
|
+
raise ProviderResponseError(
|
|
169
|
+
provider=definition.provider.value,
|
|
170
|
+
endpoint=definition.name,
|
|
171
|
+
detail=(
|
|
172
|
+
f'a {width} window starting {window.start.isoformat()} '
|
|
173
|
+
f'still returned {len(records)} records — the window '
|
|
174
|
+
f'cannot be narrowed under the provider record cap. '
|
|
175
|
+
f'The stream is denser than windowed fetching can '
|
|
176
|
+
f'enumerate; the provider feed transport is the '
|
|
177
|
+
f'escape for this endpoint at this density.'
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
tally.overflows += 1
|
|
181
|
+
logger.debug(
|
|
182
|
+
'window overflowed: provider=%s endpoint=%s window_start=%s '
|
|
183
|
+
'window_end=%s records=%d; halving',
|
|
184
|
+
definition.provider.value,
|
|
185
|
+
definition.name,
|
|
186
|
+
window.start.isoformat(),
|
|
187
|
+
window.end.isoformat(),
|
|
188
|
+
len(records),
|
|
189
|
+
)
|
|
190
|
+
midpoint = _whole_second_midpoint(window.start, window.end)
|
|
191
|
+
yield from self._drive_window(
|
|
192
|
+
definition, client, DateWindow(start=window.start, end=midpoint), tally
|
|
193
|
+
)
|
|
194
|
+
yield from self._drive_window(
|
|
195
|
+
definition, client, DateWindow(start=midpoint, end=window.end), tally
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _anchored_in(
|
|
199
|
+
self,
|
|
200
|
+
records: list[JsonObject],
|
|
201
|
+
window: DateWindow,
|
|
202
|
+
definition: EndpointDefinition[ResponseModel],
|
|
203
|
+
) -> list[JsonObject]:
|
|
204
|
+
"""Keep the records whose anchor timestamp falls in the window.
|
|
205
|
+
|
|
206
|
+
Under overlap-matched retrieval a straddler of an internal split
|
|
207
|
+
boundary is fetched by both neighboring leaves; anchoring gives it
|
|
208
|
+
exactly one owner. Records anchored outside the unit's whole
|
|
209
|
+
window (overlap edge returns) drop here too — the same records
|
|
210
|
+
the runner's window filter would drop after modeling.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
records: The leaf page's raw records.
|
|
214
|
+
window: The leaf window that owns anchored records.
|
|
215
|
+
definition: The endpoint being run, for the error detail.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The records anchored in ``[window.start, window.end)``.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ProviderResponseError: A record's anchor key is missing or
|
|
222
|
+
unparseable — the routing anchor is load-bearing, so a
|
|
223
|
+
record without one fails loudly rather than being
|
|
224
|
+
silently kept or dropped.
|
|
225
|
+
"""
|
|
226
|
+
wire_key = self.shape.event_time_wire_key
|
|
227
|
+
anchored: list[JsonObject] = []
|
|
228
|
+
for record in records:
|
|
229
|
+
raw_value = record.get(wire_key)
|
|
230
|
+
if not isinstance(raw_value, str):
|
|
231
|
+
raise ProviderResponseError(
|
|
232
|
+
provider=definition.provider.value,
|
|
233
|
+
endpoint=definition.name,
|
|
234
|
+
detail=(
|
|
235
|
+
f'record is missing the anchor timestamp '
|
|
236
|
+
f'{wire_key!r} bisection routes by.'
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
anchor = datetime.fromisoformat(raw_value)
|
|
241
|
+
except ValueError as error:
|
|
242
|
+
raise ProviderResponseError(
|
|
243
|
+
provider=definition.provider.value,
|
|
244
|
+
endpoint=definition.name,
|
|
245
|
+
detail=(
|
|
246
|
+
f'unparseable anchor timestamp {raw_value!r} under '
|
|
247
|
+
f'{wire_key!r}.'
|
|
248
|
+
),
|
|
249
|
+
) from error
|
|
250
|
+
if window.start <= anchor < window.end:
|
|
251
|
+
anchored.append(record)
|
|
252
|
+
return anchored
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _whole_second_midpoint(start: datetime, end: datetime) -> datetime:
|
|
256
|
+
"""The window's midpoint, floored to a whole second.
|
|
257
|
+
|
|
258
|
+
Fractional-second search bounds are unprobed on the wire, so splits
|
|
259
|
+
stay second-granular; the halves differ by at most one second, which
|
|
260
|
+
the recursion absorbs.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
start: The window's inclusive start.
|
|
264
|
+
end: The window's exclusive end.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
The floored midpoint, strictly between ``start`` and ``end`` for
|
|
268
|
+
any window wider than one second.
|
|
269
|
+
"""
|
|
270
|
+
half_seconds = int((end - start).total_seconds() // 2)
|
|
271
|
+
return start + timedelta(seconds=half_seconds)
|