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,153 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/streaming.py
|
|
2
|
+
"""The fetch-and-frame pipe: a driver's pages, validated and framed per batch.
|
|
3
|
+
|
|
4
|
+
``stream_processed_batches`` is the shared step the run executor's snapshot and
|
|
5
|
+
watermark arms drive, and the roster harvest (``harvest_roster_members``) drives
|
|
6
|
+
as well: it walks the
|
|
7
|
+
request driver's fetched pages and runs each through ``process_batch``, yielding
|
|
8
|
+
one ``ProcessedBatch`` per page. It is a lazy generator -- each page is framed and
|
|
9
|
+
handed to the caller before the next is fetched, so the per-page memory bound the
|
|
10
|
+
partitioned writer relies on is preserved (nothing is collected up front).
|
|
11
|
+
|
|
12
|
+
It is the non-feed pipe: ``process_batch`` transforms ``page.records`` and drops
|
|
13
|
+
``durable_progress`` (the feed cursor token the snapshot and watermark arms never
|
|
14
|
+
use), so the feed arm drives the driver's pages itself
|
|
15
|
+
(``FeedDrive``), where each page's token commits right after its
|
|
16
|
+
parquet lands. The pipe owns no state and
|
|
17
|
+
resolves no client -- the conductor (the runner, the refresh service) opens the
|
|
18
|
+
run, picks the provider's client, and consumes the stream.
|
|
19
|
+
|
|
20
|
+
The consumption half lives beside the pipe: ``BatchObserver`` is the generic
|
|
21
|
+
per-frame hook a caller may ride on a run (the caller boundary uses it to tap
|
|
22
|
+
feeder runs for roster reconciliation; the executor stays blind to what it
|
|
23
|
+
does), ``observe_batches`` threads one through a stream transparently, and
|
|
24
|
+
``drain_batches`` is the write-count-and-fold drain the snapshot arm and every
|
|
25
|
+
watermark unit run over their streams.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from collections.abc import Callable, Iterator
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
|
|
31
|
+
import polars as pl
|
|
32
|
+
|
|
33
|
+
from fleetpull.endpoints.shared import EndpointDefinition, ResumeValue
|
|
34
|
+
from fleetpull.model_contract import ResponseModel
|
|
35
|
+
from fleetpull.network.client import TransportClient
|
|
36
|
+
from fleetpull.orchestrator.batch import (
|
|
37
|
+
ProcessedBatch,
|
|
38
|
+
WindowContext,
|
|
39
|
+
combine_latest_event_time,
|
|
40
|
+
process_batch,
|
|
41
|
+
)
|
|
42
|
+
from fleetpull.orchestrator.drivers import RequestDriver
|
|
43
|
+
from fleetpull.storage import DatasetWriter
|
|
44
|
+
|
|
45
|
+
__all__: list[str] = [
|
|
46
|
+
'BatchObserver',
|
|
47
|
+
'drain_batches',
|
|
48
|
+
'observe_batches',
|
|
49
|
+
'stream_processed_batches',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# The generic per-batch hook: called with each post-validation frame as the
|
|
53
|
+
# run streams. The run executor is blind to what an observer does; an observer
|
|
54
|
+
# exception fails the run like any other batch-processing failure.
|
|
55
|
+
type BatchObserver = Callable[[pl.DataFrame], None]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def observe_batches(
|
|
59
|
+
batches: Iterator[ProcessedBatch], observer: BatchObserver | None
|
|
60
|
+
) -> Iterator[ProcessedBatch]:
|
|
61
|
+
"""Pass batches through, handing each post-validation frame to the observer.
|
|
62
|
+
|
|
63
|
+
A transparent generator wrapper: with no observer the stream is yielded
|
|
64
|
+
unchanged; with one, each batch's frame is observed before the batch is
|
|
65
|
+
yielded onward, so memory stays bounded by one batch either way.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
batches: The run's processed-batch stream.
|
|
69
|
+
observer: The per-batch hook, or ``None`` for a bare pass-through.
|
|
70
|
+
|
|
71
|
+
Yields:
|
|
72
|
+
The batches, unchanged and in order.
|
|
73
|
+
"""
|
|
74
|
+
if observer is None:
|
|
75
|
+
yield from batches
|
|
76
|
+
return
|
|
77
|
+
for processed in batches:
|
|
78
|
+
observer(processed.frame)
|
|
79
|
+
yield processed
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def drain_batches(
|
|
83
|
+
batches: Iterator[ProcessedBatch], writer: DatasetWriter
|
|
84
|
+
) -> tuple[int, datetime | None]:
|
|
85
|
+
"""Consume a processed-batch stream: write, count, and fold as it arrives.
|
|
86
|
+
|
|
87
|
+
The shared drain the snapshot arm and every watermark unit run: each
|
|
88
|
+
batch's frame is handed to
|
|
89
|
+
the writer as it yields (memory stays bounded by one batch) while the
|
|
90
|
+
fetched-row count sums and the in-window event-time maximum folds. On
|
|
91
|
+
the snapshot path every fold candidate is ``None`` (``process_batch``
|
|
92
|
+
with ``context=None``), so the fold component is ``None`` there and the
|
|
93
|
+
snapshot arm discards it.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
batches: The run's processed-batch stream, ready to consume.
|
|
97
|
+
writer: The run's dataset writer, handed each frame in order.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The fetched-row count and the folded in-window maximum event time
|
|
101
|
+
(``None`` on the snapshot path, or when no in-window event was
|
|
102
|
+
observed).
|
|
103
|
+
"""
|
|
104
|
+
records_fetched = 0
|
|
105
|
+
latest_observed: datetime | None = None
|
|
106
|
+
for processed in batches:
|
|
107
|
+
writer.write(processed.frame)
|
|
108
|
+
records_fetched += processed.frame.height
|
|
109
|
+
latest_observed = combine_latest_event_time(
|
|
110
|
+
latest_observed, processed.latest_event_time
|
|
111
|
+
)
|
|
112
|
+
return records_fetched, latest_observed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def stream_processed_batches(
|
|
116
|
+
definition: EndpointDefinition[ResponseModel],
|
|
117
|
+
driver: RequestDriver,
|
|
118
|
+
client: TransportClient,
|
|
119
|
+
resume: ResumeValue,
|
|
120
|
+
context: WindowContext | None,
|
|
121
|
+
) -> Iterator[ProcessedBatch]:
|
|
122
|
+
"""Yield each of the driver's fetched pages, validated and framed.
|
|
123
|
+
|
|
124
|
+
Drives ``driver.record_batches`` and runs each page's records through
|
|
125
|
+
``process_batch``, yielding the result. Lazy: a page is framed and yielded
|
|
126
|
+
before the next is fetched, so memory stays bounded by one page. ``resume`` is
|
|
127
|
+
what the driver injects into the first request (``None`` for a snapshot, the
|
|
128
|
+
resolved window for a watermark or backfill run); ``context`` is the per-batch
|
|
129
|
+
transform context (``None`` for a snapshot, the ``WindowContext`` for a
|
|
130
|
+
watermark run). The two are separate -- a feed run would carry a version-token
|
|
131
|
+
``resume`` with no window ``context``.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
definition: The endpoint being run (its response model, spec builder, and
|
|
135
|
+
page decoder).
|
|
136
|
+
driver: The request driver supplying the run's fetched pages.
|
|
137
|
+
client: The provider's open transport client.
|
|
138
|
+
resume: The resume value injected into the first request.
|
|
139
|
+
context: The watermark per-batch context, or ``None`` for the snapshot
|
|
140
|
+
validate-and-frame-only path.
|
|
141
|
+
|
|
142
|
+
Yields:
|
|
143
|
+
One ``ProcessedBatch`` per fetched page, in order.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
FleetpullError: A fetch, validation, framing, or guard failure, surfaced at
|
|
147
|
+
iteration so the consuming run's ``try`` records the failure.
|
|
148
|
+
|
|
149
|
+
Side Effects:
|
|
150
|
+
Issues the driver's HTTP requests as the stream is consumed.
|
|
151
|
+
"""
|
|
152
|
+
for page in driver.record_batches(definition, client, resume):
|
|
153
|
+
yield process_batch(page.records, definition, context)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/unit_loop.py
|
|
2
|
+
"""The claim-and-drive loop: concurrent, prefix-committing work-unit execution.
|
|
3
|
+
|
|
4
|
+
``drive_claimable_units`` drives a ``UnitCrew`` -- one endpoint's work-unit
|
|
5
|
+
queue bundled with the runner's per-unit callbacks -- with ``workers``
|
|
6
|
+
threads: each claims the lowest claimable unit, drives it, marks it done with
|
|
7
|
+
its folded observation, and commits the watermark prefix, until the queue is
|
|
8
|
+
drained (DESIGN sections 4/5). Claims are FIFO by ``unit_id`` (ascending
|
|
9
|
+
window order, since units enqueue chronologically) but completions may land
|
|
10
|
+
in any order -- the watermark's truth invariant no longer rests on completion
|
|
11
|
+
order. It rests on the PREFIX-ADVANCE rule (DESIGN section 5, 2026-07-20):
|
|
12
|
+
``commit_prefix`` advances the cursor only across the contiguous done-prefix
|
|
13
|
+
of the plan, through the cursor store's atomic forward-only write, so every
|
|
14
|
+
persisted watermark is true at every instant whatever the interleaving. The
|
|
15
|
+
serial-ascending constraint this loop once carried existed solely to protect
|
|
16
|
+
the per-unit advance; the prefix rule retired both together.
|
|
17
|
+
|
|
18
|
+
Failure stops the claiming, not the flight: every failing unit is logged and
|
|
19
|
+
marked ``failed`` (claimable again on a later invocation; nothing it staged
|
|
20
|
+
was committed), and the stop signal ends further claiming -- in-flight units
|
|
21
|
+
run to completion and commit (each is an independent transaction; aborting a
|
|
22
|
+
mid-write sibling buys nothing). The stop signal lands before the failed-mark
|
|
23
|
+
so a sibling's next stop check usually precedes reclaimability, but the
|
|
24
|
+
window is narrowed, not closed: a sibling already past its stop check when
|
|
25
|
+
the mark lands can claim the just-failed unit once more within the same
|
|
26
|
+
invocation -- at most one extra claim per already-running sibling, each
|
|
27
|
+
logged and each incrementing ``attempt_count``. After every worker joins, the
|
|
28
|
+
first failure re-raises.
|
|
29
|
+
|
|
30
|
+
Cross-unit parallel writes stay inside the single-writer invariant on two
|
|
31
|
+
legs, both load-bearing: units own disjoint whole-day date partitions by
|
|
32
|
+
construction (midnight-aligned, contiguous tiling), and the concurrently
|
|
33
|
+
claimable set is always one contiguous plan -- the residual is enqueued only
|
|
34
|
+
after the claim loop over leftovers drains, so no two in-flight units can
|
|
35
|
+
come from overlapping plans. Both legs hold only for ``DATE_PARTITIONED``
|
|
36
|
+
watermark cells -- the only watermark storage shipped; a future
|
|
37
|
+
(``SINGLE``, ``WatermarkMode``) cell shares one file across units and must
|
|
38
|
+
serialize its units or reject ``workers > 1`` when it is built (DESIGN
|
|
39
|
+
section 5's recorded build obligation).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
import threading
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from datetime import datetime
|
|
48
|
+
from functools import partial
|
|
49
|
+
from typing import Protocol
|
|
50
|
+
|
|
51
|
+
from fleetpull.incremental import DateWindow
|
|
52
|
+
from fleetpull.orchestrator.outcome import Executed
|
|
53
|
+
from fleetpull.orchestrator.recording import record_failure_safely
|
|
54
|
+
from fleetpull.state import ClaimedWorkUnit, WorkUnitSpec
|
|
55
|
+
from fleetpull.timing import to_iso8601
|
|
56
|
+
from fleetpull.vocabulary import Provider
|
|
57
|
+
|
|
58
|
+
__all__: list[str] = ['UnitCrew', 'UnitQueue', 'drive_claimable_units']
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UnitQueue(Protocol):
|
|
64
|
+
"""The work-unit surface the loop and the runner need (``WorkUnitStore``'s shape)."""
|
|
65
|
+
|
|
66
|
+
def enqueue(self, units: list[WorkUnitSpec]) -> int:
|
|
67
|
+
"""Insert planned units idempotently; return the newly inserted count."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def release_done_units(
|
|
71
|
+
self, provider: Provider, endpoint: str, *, window: DateWindow
|
|
72
|
+
) -> int:
|
|
73
|
+
"""Release a re-planned window's ``done`` units back to plannable."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def reset_claimed_to_pending(self, provider: Provider, endpoint: str) -> int:
|
|
77
|
+
"""Revert orphaned ``claimed`` units to ``pending`` (startup recovery)."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
def claim_next(self, provider: Provider, endpoint: str) -> ClaimedWorkUnit | None:
|
|
81
|
+
"""Atomically claim the lowest claimable unit, or return ``None``."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def mark_done(self, unit_id: int, *, observed_max: datetime | None) -> None:
|
|
85
|
+
"""Mark a claimed unit completed, recording its folded observation."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
def mark_failed(self, unit_id: int, *, error_detail: str) -> None:
|
|
89
|
+
"""Mark a claimed unit failed (claimable again on a later invocation)."""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
def done_prefix_observation(
|
|
93
|
+
self, provider: Provider, endpoint: str
|
|
94
|
+
) -> datetime | None:
|
|
95
|
+
"""The maximum observation across the contiguous done-prefix."""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class UnitCrew:
|
|
101
|
+
"""One endpoint's unit-drive collaborators: the queue and the runner's callbacks.
|
|
102
|
+
|
|
103
|
+
The identity a ``drive_claimable_units`` invocation runs over -- who
|
|
104
|
+
serves the units and what to do per unit. Pure identity, no execution
|
|
105
|
+
state: the same crew serves both the leftover pass and the residual pass
|
|
106
|
+
of one watermark run (each invocation builds its stop signal and result
|
|
107
|
+
lists fresh in the private drive).
|
|
108
|
+
|
|
109
|
+
Attributes:
|
|
110
|
+
queue: The work-unit claim queue.
|
|
111
|
+
provider: The provider whose units to drive.
|
|
112
|
+
endpoint: The endpoint whose units to drive.
|
|
113
|
+
drive_unit: Runs one unit over its window -- the runner's per-unit
|
|
114
|
+
fetch/write/record sequence -- returning its outcome.
|
|
115
|
+
commit_prefix: Advances the watermark across the contiguous
|
|
116
|
+
done-prefix (the runner's prefix commit); invoked after every
|
|
117
|
+
completion, safe under concurrent invocation by construction.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
queue: UnitQueue
|
|
121
|
+
provider: Provider
|
|
122
|
+
endpoint: str
|
|
123
|
+
drive_unit: Callable[[DateWindow], Executed]
|
|
124
|
+
commit_prefix: Callable[[], None]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _CrewDrive:
|
|
128
|
+
"""The shared state one ``drive_claimable_units`` invocation's workers race on.
|
|
129
|
+
|
|
130
|
+
A worker loop is pure claim/drive/commit; everything cross-thread lives
|
|
131
|
+
here: the stop signal that ends claiming on the first failure, and the
|
|
132
|
+
lock-guarded outcome and failure lists (append order is completion
|
|
133
|
+
order). The class exists so the worker function reads as the loop it is,
|
|
134
|
+
with the synchronization named rather than threaded through arguments.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, crew: UnitCrew) -> None:
|
|
138
|
+
self._crew = crew
|
|
139
|
+
self._stop_claiming = threading.Event()
|
|
140
|
+
self._results_lock = threading.Lock()
|
|
141
|
+
self.outcomes: list[Executed] = []
|
|
142
|
+
self.failures: list[Exception] = []
|
|
143
|
+
|
|
144
|
+
def work(self) -> None:
|
|
145
|
+
"""Claim and drive units until the queue drains or a failure stops claiming.
|
|
146
|
+
|
|
147
|
+
Side Effects:
|
|
148
|
+
Claims, completes, or fails units; drives fetches and writes;
|
|
149
|
+
commits the watermark prefix after each completion; narrates one
|
|
150
|
+
INFO per completed unit. Any exit -- drain, failure, or an
|
|
151
|
+
escaping store error -- stops further claiming crew-wide.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
while not self._stop_claiming.is_set():
|
|
155
|
+
claimed = self._crew.queue.claim_next(
|
|
156
|
+
self._crew.provider, self._crew.endpoint
|
|
157
|
+
)
|
|
158
|
+
if claimed is None:
|
|
159
|
+
return
|
|
160
|
+
self._drive_claimed(claimed)
|
|
161
|
+
finally:
|
|
162
|
+
# A drained worker means nothing is left to claim; a failed or
|
|
163
|
+
# crashed one must keep siblings from claiming more. Either way,
|
|
164
|
+
# in-flight siblings finish their own units.
|
|
165
|
+
self._stop_claiming.set()
|
|
166
|
+
|
|
167
|
+
def _drive_claimed(self, claimed: ClaimedWorkUnit) -> None:
|
|
168
|
+
"""Drive one claimed unit through completion or failure recording."""
|
|
169
|
+
window = DateWindow(start=claimed.spec.chunk_start, end=claimed.spec.chunk_end)
|
|
170
|
+
try:
|
|
171
|
+
unit_outcome = self._crew.drive_unit(window)
|
|
172
|
+
except Exception as unit_failure:
|
|
173
|
+
# Stop BEFORE the failed-mark lands. This narrows -- but does
|
|
174
|
+
# NOT close -- the same-invocation retry window: a marked-failed
|
|
175
|
+
# unit is claimable again, and a sibling already past its stop
|
|
176
|
+
# check when the mark lands can still claim it -- at most one
|
|
177
|
+
# extra claim per already-running sibling, each logged below and
|
|
178
|
+
# each incrementing attempt_count.
|
|
179
|
+
self._stop_claiming.set()
|
|
180
|
+
# Every failure is surfaced here as it lands, not only the first
|
|
181
|
+
# one that re-raises after the join -- a sibling's failure while
|
|
182
|
+
# another worker's exception wins must never vanish silently.
|
|
183
|
+
logger.exception(
|
|
184
|
+
'unit failed: provider=%s endpoint=%s unit_id=%d',
|
|
185
|
+
self._crew.provider.value,
|
|
186
|
+
self._crew.endpoint,
|
|
187
|
+
claimed.unit_id,
|
|
188
|
+
)
|
|
189
|
+
record_failure_safely(
|
|
190
|
+
partial(
|
|
191
|
+
self._crew.queue.mark_failed,
|
|
192
|
+
claimed.unit_id,
|
|
193
|
+
error_detail=str(unit_failure),
|
|
194
|
+
),
|
|
195
|
+
f'work unit {claimed.unit_id}',
|
|
196
|
+
)
|
|
197
|
+
with self._results_lock:
|
|
198
|
+
self.failures.append(unit_failure)
|
|
199
|
+
return
|
|
200
|
+
self._crew.queue.mark_done(
|
|
201
|
+
claimed.unit_id, observed_max=unit_outcome.latest_observed
|
|
202
|
+
)
|
|
203
|
+
self._crew.commit_prefix()
|
|
204
|
+
with self._results_lock:
|
|
205
|
+
self.outcomes.append(unit_outcome)
|
|
206
|
+
# The total planned count is not knowable here (the loop claims
|
|
207
|
+
# until the queue drains), so the unit narrates by its id.
|
|
208
|
+
logger.info(
|
|
209
|
+
'unit complete: provider=%s endpoint=%s unit_id=%d '
|
|
210
|
+
'window_start=%s window_end=%s records_fetched=%d',
|
|
211
|
+
self._crew.provider.value,
|
|
212
|
+
self._crew.endpoint,
|
|
213
|
+
claimed.unit_id,
|
|
214
|
+
to_iso8601(window.start),
|
|
215
|
+
to_iso8601(window.end),
|
|
216
|
+
unit_outcome.records_fetched,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def drive_claimable_units(crew: UnitCrew, *, workers: int) -> list[Executed]:
|
|
221
|
+
"""Claim and drive every claimable unit, ``workers`` at a time.
|
|
222
|
+
|
|
223
|
+
Each worker repeatedly claims the lowest claimable unit (FIFO by
|
|
224
|
+
``unit_id``), drives it through the crew's ``drive_unit``, marks it done
|
|
225
|
+
with its folded observation, and invokes the crew's ``commit_prefix``;
|
|
226
|
+
the invocation ends when nothing is claimable. The first failure marks
|
|
227
|
+
its unit ``failed`` (claimable again next invocation; nothing it staged
|
|
228
|
+
was committed) and stops further claiming; in-flight units complete and
|
|
229
|
+
commit, and the failure re-raises after every worker joins. The stop
|
|
230
|
+
lands before the failed-mark, which narrows but does not close the
|
|
231
|
+
same-invocation retry window: a sibling already past its stop check can
|
|
232
|
+
claim the just-failed unit once more, so a persistently failing unit can
|
|
233
|
+
fail up to once per already-running sibling before the invocation ends
|
|
234
|
+
-- each failure logged, each incrementing ``attempt_count``.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
crew: The queue and per-unit callbacks to drive.
|
|
238
|
+
workers: The number of units in flight at once; at least 1. One
|
|
239
|
+
worker runs inline on the calling thread (no pool), preserving
|
|
240
|
+
the serial path as the degenerate case.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Each driven unit's ``Executed`` outcome, in completion order (empty
|
|
244
|
+
when nothing was claimable).
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ValueError: ``workers`` is below 1.
|
|
248
|
+
Exception: The first failing unit's exception, re-raised after every
|
|
249
|
+
worker joins. Every failure -- first or later -- is logged with
|
|
250
|
+
its unit id as it lands and its unit marked ``failed``; only the
|
|
251
|
+
first re-raises.
|
|
252
|
+
|
|
253
|
+
Side Effects:
|
|
254
|
+
Claims, completes, or fails units in the queue; commits the
|
|
255
|
+
watermark prefix per completion; narrates one INFO per completed
|
|
256
|
+
unit and one exception log per failed unit; whatever ``drive_unit``
|
|
257
|
+
performs (network fetches, parquet writes, ledger commits).
|
|
258
|
+
"""
|
|
259
|
+
if workers < 1:
|
|
260
|
+
raise ValueError(f'workers must be at least 1, got {workers}')
|
|
261
|
+
drive = _CrewDrive(crew)
|
|
262
|
+
if workers == 1:
|
|
263
|
+
drive.work()
|
|
264
|
+
else:
|
|
265
|
+
thread_prefix = f'fleetpull-units-{crew.provider.value}-{crew.endpoint}'
|
|
266
|
+
with ThreadPoolExecutor(
|
|
267
|
+
max_workers=workers, thread_name_prefix=thread_prefix
|
|
268
|
+
) as pool:
|
|
269
|
+
for worker_future in [pool.submit(drive.work) for _ in range(workers)]:
|
|
270
|
+
worker_future.result()
|
|
271
|
+
if drive.failures:
|
|
272
|
+
raise drive.failures[0]
|
|
273
|
+
return drive.outcomes
|