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,350 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/drivers.py
|
|
2
|
+
"""Request drivers: the run executor's request-cardinality seam.
|
|
3
|
+
|
|
4
|
+
A ``RequestDriver`` owns how many request chains one endpoint run issues, and yields
|
|
5
|
+
the run's fetched pages as a stream of batches. ``SingleRequestDriver`` issues
|
|
6
|
+
exactly one request chain (``member_values={}``) and yields its pages one at a
|
|
7
|
+
time; ``FanOutRequestDriver`` issues one chain per supplied member
|
|
8
|
+
(``member_values={member_key: member}``), fetching members concurrently on its
|
|
9
|
+
injected ``FetchPool`` and yielding each member's pages in member order -- the
|
|
10
|
+
member list is the caller's (a ``RosterFanOut``'s whole roster, fanned once per
|
|
11
|
+
work unit's window, a ``BatchedRosterFanOut``'s comma-joined batches, or a
|
|
12
|
+
``ParamSweep``'s declared values; the driver is member-agnostic). ``member_values`` live only here -- the run executor never
|
|
13
|
+
builds them and the orchestration entry never supplies them; only the driver
|
|
14
|
+
does. A driver touches just the endpoint's ``SpecBuilder`` and the transport
|
|
15
|
+
client, and yields whole ``FetchedPage`` objects (records and durable
|
|
16
|
+
progress); validation, framing, and writing are the run executor's. The batch
|
|
17
|
+
granularity is each driver's own choice; the runner consumes batches uniformly.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from functools import partial
|
|
24
|
+
from typing import Final, Protocol
|
|
25
|
+
|
|
26
|
+
from fleetpull.endpoints.shared import (
|
|
27
|
+
CompletenessCheck,
|
|
28
|
+
EndpointDefinition,
|
|
29
|
+
ResumeValue,
|
|
30
|
+
)
|
|
31
|
+
from fleetpull.exceptions import ProviderResponseError
|
|
32
|
+
from fleetpull.model_contract import ResponseModel
|
|
33
|
+
from fleetpull.network.client import FetchedPage, TransportClient
|
|
34
|
+
from fleetpull.orchestrator.fanout import FetchPool, stream_pieces
|
|
35
|
+
|
|
36
|
+
__all__: list[str] = ['FanOutRequestDriver', 'RequestDriver', 'SingleRequestDriver']
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# The fan-out progress heartbeat's cadence: a narration cadence, not a
|
|
41
|
+
# correctness knob. A full-fleet fan-out of ~1,500 members narrates ~15
|
|
42
|
+
# progress lines; a small fleet emits at most one.
|
|
43
|
+
_MEMBER_PROGRESS_INTERVAL: Final[int] = 100
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RequestDriver(Protocol):
|
|
47
|
+
"""The request-cardinality seam: yield the run's fetched pages as batches.
|
|
48
|
+
|
|
49
|
+
The run executor drives the returned iterator, consuming one batch per
|
|
50
|
+
iteration: it reads ``page.records`` to validate -> frame -> write and
|
|
51
|
+
``page.durable_progress`` to advance a feed cursor. Both concrete drivers
|
|
52
|
+
yield one fetched page per batch. A plain Protocol -- the run executor
|
|
53
|
+
receives a concrete driver and calls it, never verifies it.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def record_batches(
|
|
57
|
+
self,
|
|
58
|
+
definition: EndpointDefinition[ResponseModel],
|
|
59
|
+
client: TransportClient,
|
|
60
|
+
resume: ResumeValue,
|
|
61
|
+
) -> Iterator[FetchedPage]:
|
|
62
|
+
"""Yield the run's fetched pages, one batch at a time.
|
|
63
|
+
|
|
64
|
+
Each yielded ``FetchedPage`` carries the page's records and its durable
|
|
65
|
+
progress; the run executor reads ``page.records`` to validate/frame/write
|
|
66
|
+
and ``page.durable_progress`` to advance a feed cursor.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
definition: The endpoint being run (read for its ``spec_builder``,
|
|
70
|
+
``page_decoder``, and ``quota_scope``).
|
|
71
|
+
client: The transport client for this endpoint's provider.
|
|
72
|
+
resume: The resume value injected into the first request -- ``None``
|
|
73
|
+
for a snapshot, the resolved window for a watermark endpoint,
|
|
74
|
+
the stored token or cold-start seed for a feed endpoint.
|
|
75
|
+
|
|
76
|
+
Yields:
|
|
77
|
+
One ``FetchedPage`` per batch, in order.
|
|
78
|
+
"""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _stream_chain_pages(
|
|
83
|
+
definition: EndpointDefinition[ResponseModel],
|
|
84
|
+
client: TransportClient,
|
|
85
|
+
resume: ResumeValue,
|
|
86
|
+
member_values: Mapping[str, str],
|
|
87
|
+
) -> Iterator[FetchedPage]:
|
|
88
|
+
"""Issue one request chain and yield its fetched pages in order.
|
|
89
|
+
|
|
90
|
+
The chain primitive both drivers compose: build the first request for
|
|
91
|
+
``member_values`` and stream every page. Yields each ``FetchedPage`` whole
|
|
92
|
+
(records and durable progress), so the feed cursor token survives to the run
|
|
93
|
+
executor; holds nothing across pages, so memory stays bounded by one page
|
|
94
|
+
regardless of how wide the window or how many rows a member has.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
definition: The endpoint being run (its ``spec_builder``, ``page_decoder``,
|
|
98
|
+
and ``quota_scope``).
|
|
99
|
+
client: The transport client for this endpoint's provider.
|
|
100
|
+
resume: The resume value injected into the first request.
|
|
101
|
+
member_values: The per-chain member binding -- empty for a lone chain,
|
|
102
|
+
``{member_key: member}`` for one fan-out or sweep member.
|
|
103
|
+
|
|
104
|
+
Yields:
|
|
105
|
+
Each fetched page in order. ``fetch_pages`` always drives at least one
|
|
106
|
+
page, so at least one (possibly empty) page yields.
|
|
107
|
+
"""
|
|
108
|
+
spec = definition.spec_builder.build_spec(
|
|
109
|
+
resume=resume, member_values=member_values
|
|
110
|
+
)
|
|
111
|
+
yield from client.fetch_pages(
|
|
112
|
+
spec, definition.page_decoder, definition.quota_scope.value
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SingleRequestDriver:
|
|
117
|
+
"""Issue exactly one request chain and stream its pages one at a time.
|
|
118
|
+
|
|
119
|
+
The driver for every ``SingleFetch``-shaped endpoint (snapshots, and any
|
|
120
|
+
single-chain windowed endpoint). Builds the first request with
|
|
121
|
+
``member_values={}`` and yields each page as its own batch -- no per-chain
|
|
122
|
+
collection, so the run executor writes (and the partitioned writer stages
|
|
123
|
+
to disk) one page at a time.
|
|
124
|
+
|
|
125
|
+
A declared ``completeness_check`` changes none of that streaming: the pages
|
|
126
|
+
flow exactly as on the undeclared path while a running record count
|
|
127
|
+
accumulates, and after the terminal page the provider-reported expected
|
|
128
|
+
count is fetched once and compared -- a mismatch fails the run loudly (the
|
|
129
|
+
next scheduled run is the retry; probe-settled decision 2 as amended
|
|
130
|
+
2026-07-13).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def record_batches(
|
|
134
|
+
self,
|
|
135
|
+
definition: EndpointDefinition[ResponseModel],
|
|
136
|
+
client: TransportClient,
|
|
137
|
+
resume: ResumeValue,
|
|
138
|
+
) -> Iterator[FetchedPage]:
|
|
139
|
+
"""Yield one batch per page of the single request chain.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
definition: The endpoint being run.
|
|
143
|
+
client: The transport client for this endpoint's provider.
|
|
144
|
+
resume: The resume value injected into the first request.
|
|
145
|
+
|
|
146
|
+
Yields:
|
|
147
|
+
One ``FetchedPage`` per page, in order. ``fetch_pages`` always drives
|
|
148
|
+
at least one page, so at least one (possibly empty) batch yields.
|
|
149
|
+
A declared ``completeness_check`` leaves the stream untouched; the
|
|
150
|
+
count is proven after the terminal page.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ProviderResponseError: A declared completeness check mismatched the
|
|
154
|
+
harvest (raised after the final yield, before the consumer can
|
|
155
|
+
treat the run as complete).
|
|
156
|
+
"""
|
|
157
|
+
check = definition.completeness_check
|
|
158
|
+
if check is None:
|
|
159
|
+
yield from _stream_chain_pages(definition, client, resume, {})
|
|
160
|
+
return
|
|
161
|
+
yield from _stream_then_verify_pages(definition, client, resume, check)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _stream_then_verify_pages(
|
|
165
|
+
definition: EndpointDefinition[ResponseModel],
|
|
166
|
+
client: TransportClient,
|
|
167
|
+
resume: ResumeValue,
|
|
168
|
+
check: CompletenessCheck,
|
|
169
|
+
) -> Iterator[FetchedPage]:
|
|
170
|
+
"""Stream the chain unbuffered, then prove the count after the last page.
|
|
171
|
+
|
|
172
|
+
The completeness guard (probe-settled decision 2, amended 2026-07-13):
|
|
173
|
+
pages yield exactly as the unguarded chain's do while a running record
|
|
174
|
+
count accumulates; after the terminal page the declared check fires once
|
|
175
|
+
through the same client and quota scope, and a mismatch raises with both
|
|
176
|
+
counts -- exact match, no tolerance number, no refetch. The raise happens
|
|
177
|
+
after the final yield, so it reaches the consuming runner before it can
|
|
178
|
+
treat the run as complete: staging is discarded, the ledger row fails, and
|
|
179
|
+
the prior parquet stands. The next scheduled run is the retry.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
definition: The endpoint being run (its ``quota_scope`` prices the
|
|
183
|
+
check's request).
|
|
184
|
+
client: The open transport client the harvest and the check share.
|
|
185
|
+
resume: The resume value injected into the first request (always
|
|
186
|
+
``None`` here -- the declaration is construction-restricted to
|
|
187
|
+
snapshots).
|
|
188
|
+
check: The endpoint's declared completeness check.
|
|
189
|
+
|
|
190
|
+
Yields:
|
|
191
|
+
Every fetched page, in order, unbuffered.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
ProviderResponseError: Expected and harvested counts disagree; the
|
|
195
|
+
detail names both.
|
|
196
|
+
"""
|
|
197
|
+
harvested_count = 0
|
|
198
|
+
for page in _stream_chain_pages(definition, client, resume, {}):
|
|
199
|
+
harvested_count += len(page.records)
|
|
200
|
+
yield page
|
|
201
|
+
expected_count = check.expected_count(client, definition.quota_scope.value)
|
|
202
|
+
if harvested_count != expected_count:
|
|
203
|
+
raise ProviderResponseError(
|
|
204
|
+
provider=definition.provider.value,
|
|
205
|
+
endpoint=definition.name,
|
|
206
|
+
detail=(
|
|
207
|
+
f'completeness check failed -- provider expects '
|
|
208
|
+
f'{expected_count} records, harvest returned {harvested_count}'
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True, slots=True)
|
|
214
|
+
class FanOutRequestDriver:
|
|
215
|
+
"""Issue one request chain per member, fetched concurrently, yielded in order.
|
|
216
|
+
|
|
217
|
+
The driver for endpoints that fan a request out over per-member values --
|
|
218
|
+
a ``RosterFanOut``'s roster members (the per-vehicle ``vehicle_locations``
|
|
219
|
+
endpoint), a ``BatchedRosterFanOut``'s comma-joined batches, or a
|
|
220
|
+
``ParamSweep``'s declared values; the driver is member-agnostic and
|
|
221
|
+
never knows which shape supplied its list. Each member
|
|
222
|
+
is one piece: a worker on the injected ``fetch_pool`` runs that member's
|
|
223
|
+
whole chain (``member_values={member_key: member}``, tokens and the
|
|
224
|
+
concurrency semaphore acquired per attempt exactly as a serial fetch
|
|
225
|
+
would), and the consuming thread receives the pages through the bounded
|
|
226
|
+
channel (``stream_pieces``) in member order -- so memory holds at most
|
|
227
|
+
``submission_window + 1`` members' pages at once, a function of the pool
|
|
228
|
+
size, never of the member count. The member list is the caller's: the
|
|
229
|
+
whole roster or value set, fanned once per work unit's window (units carry
|
|
230
|
+
no member key). ``member_values`` (and so the fan-out) live only here; the
|
|
231
|
+
shape resolution supplies the members and the member key already
|
|
232
|
+
extracted, never ``member_values`` and never the endpoint's
|
|
233
|
+
``request_shape``.
|
|
234
|
+
|
|
235
|
+
``completeness_check`` is deliberately not consulted: a fanned definition
|
|
236
|
+
can never declare one (``EndpointDefinition`` rejects the pairing at
|
|
237
|
+
construction) -- a per-member run is not the one complete listing an
|
|
238
|
+
expected-count comparison would be meaningful against.
|
|
239
|
+
|
|
240
|
+
Attributes:
|
|
241
|
+
members: The member values to issue one chain each for, in order.
|
|
242
|
+
member_key: The key each member binds under in ``member_values``
|
|
243
|
+
(``RosterFanOut.member_key``, ``BatchedRosterFanOut.member_key``,
|
|
244
|
+
or ``ParamSweep.param``); the spec
|
|
245
|
+
builder owns its interpretation.
|
|
246
|
+
fetch_pool: The provider's fetch workers and channel bound (from the
|
|
247
|
+
composition root's ``FetchPoolRegistry``; tests inject a
|
|
248
|
+
synchronous same-thread executor through this same seam).
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
members: Sequence[str]
|
|
252
|
+
member_key: str
|
|
253
|
+
fetch_pool: FetchPool
|
|
254
|
+
|
|
255
|
+
def record_batches(
|
|
256
|
+
self,
|
|
257
|
+
definition: EndpointDefinition[ResponseModel],
|
|
258
|
+
client: TransportClient,
|
|
259
|
+
resume: ResumeValue,
|
|
260
|
+
) -> Iterator[FetchedPage]:
|
|
261
|
+
"""Yield each member's fetched pages, one chain per member, in member order.
|
|
262
|
+
|
|
263
|
+
Members fetch concurrently on the pool's workers; pages still yield
|
|
264
|
+
member by member, in the given order, so the consumer observes the
|
|
265
|
+
serial loop's stream. The first failing member's exception fails the
|
|
266
|
+
run: members past the channel's window are never requested, in-flight
|
|
267
|
+
members finish and are discarded (a discarded failure is logged, never
|
|
268
|
+
raised over the first).
|
|
269
|
+
|
|
270
|
+
Progress narrates on this consuming side of the channel -- the one
|
|
271
|
+
seam that sees every member exactly once, in member order, on the
|
|
272
|
+
serial and threaded paths alike: one DEBUG per member, an INFO
|
|
273
|
+
heartbeat every ``_MEMBER_PROGRESS_INTERVAL`` members, and one INFO
|
|
274
|
+
when the fan-out drains. Never per page or per record -- that is
|
|
275
|
+
flood, not progress.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
definition: The endpoint being run.
|
|
279
|
+
client: The transport client for this endpoint's provider (safe to
|
|
280
|
+
share across the pool's workers -- the client is reentrant).
|
|
281
|
+
resume: The resume value injected into every member's first request
|
|
282
|
+
(the shared window -- one watermark, fanned across members).
|
|
283
|
+
|
|
284
|
+
Yields:
|
|
285
|
+
Each fetched page, member by member, in order. Each member drives at
|
|
286
|
+
least one (possibly empty) page.
|
|
287
|
+
"""
|
|
288
|
+
member_total = len(self.members)
|
|
289
|
+
piece_tasks = (
|
|
290
|
+
partial(self._fetch_member_pages, definition, client, resume, member)
|
|
291
|
+
for member in self.members
|
|
292
|
+
)
|
|
293
|
+
for member_ordinal, (member, member_pages) in enumerate(
|
|
294
|
+
stream_pieces(piece_tasks, self.fetch_pool), start=1
|
|
295
|
+
):
|
|
296
|
+
logger.debug(
|
|
297
|
+
'fetched member: provider=%s endpoint=%s member=%s (%d/%d)',
|
|
298
|
+
definition.provider.value,
|
|
299
|
+
definition.name,
|
|
300
|
+
member,
|
|
301
|
+
member_ordinal,
|
|
302
|
+
member_total,
|
|
303
|
+
)
|
|
304
|
+
if member_ordinal % _MEMBER_PROGRESS_INTERVAL == 0:
|
|
305
|
+
logger.info(
|
|
306
|
+
'fan-out progress: provider=%s endpoint=%s members=%d/%d',
|
|
307
|
+
definition.provider.value,
|
|
308
|
+
definition.name,
|
|
309
|
+
member_ordinal,
|
|
310
|
+
member_total,
|
|
311
|
+
)
|
|
312
|
+
yield from member_pages
|
|
313
|
+
logger.info(
|
|
314
|
+
'fan-out complete: provider=%s endpoint=%s members=%d',
|
|
315
|
+
definition.provider.value,
|
|
316
|
+
definition.name,
|
|
317
|
+
member_total,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _fetch_member_pages(
|
|
321
|
+
self,
|
|
322
|
+
definition: EndpointDefinition[ResponseModel],
|
|
323
|
+
client: TransportClient,
|
|
324
|
+
resume: ResumeValue,
|
|
325
|
+
member: str,
|
|
326
|
+
) -> list[tuple[str, list[FetchedPage]]]:
|
|
327
|
+
"""Fetch one member's whole chain -- the piece a pool worker executes.
|
|
328
|
+
|
|
329
|
+
Runs on a worker thread: it touches the transport (which owns the
|
|
330
|
+
limiter consultation) and nothing else -- no validation, no framing,
|
|
331
|
+
no logging, no writing, per the single-writer invariant. The
|
|
332
|
+
one-element return keeps the channel yielding each member whole, so
|
|
333
|
+
the consuming thread narrates member completions in member order;
|
|
334
|
+
memory stays bounded by one member's whole page list per in-flight
|
|
335
|
+
piece.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
definition: The endpoint being run.
|
|
339
|
+
client: The provider's reentrant transport client.
|
|
340
|
+
resume: The resume value injected into the member's first request.
|
|
341
|
+
member: The fan-out key this piece fetches.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
A one-element sequence pairing the member with its pages, in
|
|
345
|
+
chain order -- at least one (possibly empty) page.
|
|
346
|
+
"""
|
|
347
|
+
member_pages = list(
|
|
348
|
+
_stream_chain_pages(definition, client, resume, {self.member_key: member})
|
|
349
|
+
)
|
|
350
|
+
return [(member, member_pages)]
|