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.
Files changed (252) hide show
  1. fleetpull/__init__.py +36 -0
  2. fleetpull/api/__init__.py +32 -0
  3. fleetpull/api/auth_ingress.py +216 -0
  4. fleetpull/api/catalog.py +139 -0
  5. fleetpull/api/fetch.py +229 -0
  6. fleetpull/api/identity.py +90 -0
  7. fleetpull/api/sync.py +735 -0
  8. fleetpull/cli.py +146 -0
  9. fleetpull/config/__init__.py +47 -0
  10. fleetpull/config/base.py +18 -0
  11. fleetpull/config/example.py +90 -0
  12. fleetpull/config/geotab.py +78 -0
  13. fleetpull/config/http.py +32 -0
  14. fleetpull/config/loading.py +195 -0
  15. fleetpull/config/logger.py +139 -0
  16. fleetpull/config/providers.py +520 -0
  17. fleetpull/config/rate_limit.py +50 -0
  18. fleetpull/config/resolution.py +156 -0
  19. fleetpull/config/retry.py +69 -0
  20. fleetpull/config/root.py +157 -0
  21. fleetpull/config/sections.py +146 -0
  22. fleetpull/endpoints/__init__.py +24 -0
  23. fleetpull/endpoints/geotab/__init__.py +12 -0
  24. fleetpull/endpoints/geotab/_requests.py +427 -0
  25. fleetpull/endpoints/geotab/annotation_logs.py +73 -0
  26. fleetpull/endpoints/geotab/audits.py +70 -0
  27. fleetpull/endpoints/geotab/devices.py +79 -0
  28. fleetpull/endpoints/geotab/driver_changes.py +73 -0
  29. fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
  30. fleetpull/endpoints/geotab/dvir_logs.py +76 -0
  31. fleetpull/endpoints/geotab/exception_events.py +119 -0
  32. fleetpull/endpoints/geotab/fault_data.py +72 -0
  33. fleetpull/endpoints/geotab/fill_ups.py +79 -0
  34. fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
  35. fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
  36. fleetpull/endpoints/geotab/log_records.py +74 -0
  37. fleetpull/endpoints/geotab/media_files.py +74 -0
  38. fleetpull/endpoints/geotab/shipment_logs.py +71 -0
  39. fleetpull/endpoints/geotab/status_data.py +71 -0
  40. fleetpull/endpoints/geotab/text_messages.py +77 -0
  41. fleetpull/endpoints/geotab/trips.py +102 -0
  42. fleetpull/endpoints/geotab/users.py +81 -0
  43. fleetpull/endpoints/motive/__init__.py +12 -0
  44. fleetpull/endpoints/motive/_spec_builders.py +94 -0
  45. fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
  46. fleetpull/endpoints/motive/driving_periods.py +85 -0
  47. fleetpull/endpoints/motive/groups.py +61 -0
  48. fleetpull/endpoints/motive/idle_events.py +94 -0
  49. fleetpull/endpoints/motive/users.py +64 -0
  50. fleetpull/endpoints/motive/vehicle_locations.py +170 -0
  51. fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
  52. fleetpull/endpoints/motive/vehicles.py +106 -0
  53. fleetpull/endpoints/registry.py +280 -0
  54. fleetpull/endpoints/samsara/__init__.py +12 -0
  55. fleetpull/endpoints/samsara/_spec_builders.py +202 -0
  56. fleetpull/endpoints/samsara/addresses.py +77 -0
  57. fleetpull/endpoints/samsara/asset_locations.py +217 -0
  58. fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
  59. fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
  60. fleetpull/endpoints/samsara/drivers.py +169 -0
  61. fleetpull/endpoints/samsara/engine_states.py +114 -0
  62. fleetpull/endpoints/samsara/gps_readings.py +113 -0
  63. fleetpull/endpoints/samsara/idling_events.py +195 -0
  64. fleetpull/endpoints/samsara/odometer_readings.py +116 -0
  65. fleetpull/endpoints/samsara/trips.py +217 -0
  66. fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
  67. fleetpull/endpoints/samsara/vehicles.py +121 -0
  68. fleetpull/endpoints/shared/__init__.py +57 -0
  69. fleetpull/endpoints/shared/base.py +529 -0
  70. fleetpull/endpoints/shared/request_shape.py +227 -0
  71. fleetpull/endpoints/shared/resume.py +74 -0
  72. fleetpull/endpoints/shared/spec_builders.py +59 -0
  73. fleetpull/endpoints/shared/sync_mode.py +159 -0
  74. fleetpull/endpoints/shared/url_paths.py +149 -0
  75. fleetpull/exceptions.py +320 -0
  76. fleetpull/incremental/__init__.py +27 -0
  77. fleetpull/incremental/cursor.py +66 -0
  78. fleetpull/incremental/resolution.py +152 -0
  79. fleetpull/incremental/seed.py +53 -0
  80. fleetpull/incremental/window.py +94 -0
  81. fleetpull/logger/__init__.py +5 -0
  82. fleetpull/logger/setup.py +145 -0
  83. fleetpull/model_contract/__init__.py +6 -0
  84. fleetpull/model_contract/coercions.py +33 -0
  85. fleetpull/model_contract/response.py +45 -0
  86. fleetpull/models/__init__.py +7 -0
  87. fleetpull/models/geotab/__init__.py +166 -0
  88. fleetpull/models/geotab/annotation_log.py +109 -0
  89. fleetpull/models/geotab/audit.py +56 -0
  90. fleetpull/models/geotab/device.py +217 -0
  91. fleetpull/models/geotab/driver_change.py +102 -0
  92. fleetpull/models/geotab/duty_status_log.py +200 -0
  93. fleetpull/models/geotab/dvir_log.py +184 -0
  94. fleetpull/models/geotab/exception_event.py +135 -0
  95. fleetpull/models/geotab/fault_data.py +168 -0
  96. fleetpull/models/geotab/fill_up.py +189 -0
  97. fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
  98. fleetpull/models/geotab/fuel_tax_detail.py +150 -0
  99. fleetpull/models/geotab/log_record.py +68 -0
  100. fleetpull/models/geotab/media_file.py +133 -0
  101. fleetpull/models/geotab/shared.py +221 -0
  102. fleetpull/models/geotab/shipment_log.py +105 -0
  103. fleetpull/models/geotab/status_data.py +108 -0
  104. fleetpull/models/geotab/text_message.py +125 -0
  105. fleetpull/models/geotab/trip.py +162 -0
  106. fleetpull/models/geotab/user.py +187 -0
  107. fleetpull/models/motive/__init__.py +43 -0
  108. fleetpull/models/motive/driver_idle_rollup.py +109 -0
  109. fleetpull/models/motive/driving_period.py +82 -0
  110. fleetpull/models/motive/group.py +49 -0
  111. fleetpull/models/motive/idle_event.py +77 -0
  112. fleetpull/models/motive/shared.py +192 -0
  113. fleetpull/models/motive/user.py +217 -0
  114. fleetpull/models/motive/vehicle.py +162 -0
  115. fleetpull/models/motive/vehicle_location.py +127 -0
  116. fleetpull/models/motive/vehicle_utilization.py +128 -0
  117. fleetpull/models/samsara/__init__.py +108 -0
  118. fleetpull/models/samsara/address.py +149 -0
  119. fleetpull/models/samsara/asset_location.py +131 -0
  120. fleetpull/models/samsara/driver.py +232 -0
  121. fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
  122. fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
  123. fleetpull/models/samsara/engine_state.py +92 -0
  124. fleetpull/models/samsara/gps_reading.py +146 -0
  125. fleetpull/models/samsara/idling_event.py +174 -0
  126. fleetpull/models/samsara/odometer_reading.py +90 -0
  127. fleetpull/models/samsara/trip.py +199 -0
  128. fleetpull/models/samsara/vehicle.py +167 -0
  129. fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
  130. fleetpull/network/__init__.py +9 -0
  131. fleetpull/network/auth/__init__.py +15 -0
  132. fleetpull/network/auth/authenticate.py +271 -0
  133. fleetpull/network/auth/manager.py +223 -0
  134. fleetpull/network/auth/models.py +57 -0
  135. fleetpull/network/auth/strategies.py +169 -0
  136. fleetpull/network/classifiers/__init__.py +11 -0
  137. fleetpull/network/classifiers/geotab.py +145 -0
  138. fleetpull/network/classifiers/motive.py +79 -0
  139. fleetpull/network/classifiers/samsara.py +80 -0
  140. fleetpull/network/client/__init__.py +25 -0
  141. fleetpull/network/client/page.py +32 -0
  142. fleetpull/network/client/profile.py +27 -0
  143. fleetpull/network/client/registry.py +103 -0
  144. fleetpull/network/client/registry_base.py +150 -0
  145. fleetpull/network/client/runtime.py +44 -0
  146. fleetpull/network/client/transport.py +381 -0
  147. fleetpull/network/contract/__init__.py +50 -0
  148. fleetpull/network/contract/auth.py +48 -0
  149. fleetpull/network/contract/classifier.py +189 -0
  150. fleetpull/network/contract/envelope_fetcher.py +33 -0
  151. fleetpull/network/contract/envelopes.py +189 -0
  152. fleetpull/network/contract/outcome.py +43 -0
  153. fleetpull/network/contract/page_decoder.py +101 -0
  154. fleetpull/network/contract/request.py +97 -0
  155. fleetpull/network/decoders/__init__.py +40 -0
  156. fleetpull/network/decoders/_window_stamp.py +78 -0
  157. fleetpull/network/decoders/geotab.py +309 -0
  158. fleetpull/network/decoders/motive.py +204 -0
  159. fleetpull/network/decoders/motive_reports.py +114 -0
  160. fleetpull/network/decoders/samsara.py +343 -0
  161. fleetpull/network/decoders/samsara_reports.py +119 -0
  162. fleetpull/network/decoders/single_page.py +55 -0
  163. fleetpull/network/limits/__init__.py +13 -0
  164. fleetpull/network/limits/bucket_math.py +58 -0
  165. fleetpull/network/limits/limiter.py +153 -0
  166. fleetpull/network/limits/registry.py +96 -0
  167. fleetpull/network/posture/__init__.py +6 -0
  168. fleetpull/network/posture/client_options.py +96 -0
  169. fleetpull/network/retry/__init__.py +13 -0
  170. fleetpull/network/retry/decision.py +155 -0
  171. fleetpull/network/tls/__init__.py +5 -0
  172. fleetpull/network/tls/truststore_context.py +35 -0
  173. fleetpull/orchestrator/__init__.py +30 -0
  174. fleetpull/orchestrator/backfill.py +127 -0
  175. fleetpull/orchestrator/batch.py +155 -0
  176. fleetpull/orchestrator/bisection.py +271 -0
  177. fleetpull/orchestrator/drivers.py +350 -0
  178. fleetpull/orchestrator/entry.py +347 -0
  179. fleetpull/orchestrator/executors.py +128 -0
  180. fleetpull/orchestrator/fanout.py +163 -0
  181. fleetpull/orchestrator/feed_drive.py +266 -0
  182. fleetpull/orchestrator/metadata_projection.py +195 -0
  183. fleetpull/orchestrator/outcome.py +53 -0
  184. fleetpull/orchestrator/recording.py +85 -0
  185. fleetpull/orchestrator/resume.py +143 -0
  186. fleetpull/orchestrator/roster_harvest.py +108 -0
  187. fleetpull/orchestrator/roster_refresh.py +363 -0
  188. fleetpull/orchestrator/runner.py +262 -0
  189. fleetpull/orchestrator/shape_resolution.py +221 -0
  190. fleetpull/orchestrator/spine.py +166 -0
  191. fleetpull/orchestrator/streaming.py +153 -0
  192. fleetpull/orchestrator/unit_loop.py +273 -0
  193. fleetpull/orchestrator/watermark_drive.py +455 -0
  194. fleetpull/paths/__init__.py +13 -0
  195. fleetpull/paths/datasets.py +38 -0
  196. fleetpull/paths/partitions.py +41 -0
  197. fleetpull/paths/resolution.py +98 -0
  198. fleetpull/polars_typing/__init__.py +20 -0
  199. fleetpull/py.typed +0 -0
  200. fleetpull/records/__init__.py +19 -0
  201. fleetpull/records/convert.py +53 -0
  202. fleetpull/records/dataframe.py +63 -0
  203. fleetpull/records/event_time.py +70 -0
  204. fleetpull/records/fields.py +190 -0
  205. fleetpull/records/flatten.py +54 -0
  206. fleetpull/records/roster_members.py +74 -0
  207. fleetpull/records/schema.py +73 -0
  208. fleetpull/records/validation.py +62 -0
  209. fleetpull/resources/__init__.py +10 -0
  210. fleetpull/resources/config.example.yaml +222 -0
  211. fleetpull/roster/__init__.py +16 -0
  212. fleetpull/roster/definition.py +46 -0
  213. fleetpull/roster/key.py +40 -0
  214. fleetpull/roster/registry.py +93 -0
  215. fleetpull/state/__init__.py +36 -0
  216. fleetpull/state/cursors.py +417 -0
  217. fleetpull/state/database.py +463 -0
  218. fleetpull/state/migrations.py +430 -0
  219. fleetpull/state/reconcile.py +127 -0
  220. fleetpull/state/rosters.py +159 -0
  221. fleetpull/state/run_ledger.py +572 -0
  222. fleetpull/state/work_units.py +648 -0
  223. fleetpull/storage/__init__.py +30 -0
  224. fleetpull/storage/append.py +187 -0
  225. fleetpull/storage/atomic.py +161 -0
  226. fleetpull/storage/files.py +176 -0
  227. fleetpull/storage/frames.py +98 -0
  228. fleetpull/storage/metadata.py +161 -0
  229. fleetpull/storage/partitioned.py +205 -0
  230. fleetpull/storage/pruning.py +169 -0
  231. fleetpull/storage/read.py +35 -0
  232. fleetpull/storage/result.py +34 -0
  233. fleetpull/storage/single_file.py +130 -0
  234. fleetpull/storage/splitting.py +88 -0
  235. fleetpull/storage/staging.py +178 -0
  236. fleetpull/storage/writers.py +145 -0
  237. fleetpull/timing/__init__.py +21 -0
  238. fleetpull/timing/canon.py +92 -0
  239. fleetpull/timing/clock.py +192 -0
  240. fleetpull/timing/codec.py +122 -0
  241. fleetpull/timing/sleeper.py +65 -0
  242. fleetpull/vocabulary/__init__.py +16 -0
  243. fleetpull/vocabulary/json_types.py +20 -0
  244. fleetpull/vocabulary/provider.py +29 -0
  245. fleetpull/vocabulary/quota_scope.py +47 -0
  246. fleetpull/vocabulary/response_category.py +32 -0
  247. fleetpull-0.1.0.dist-info/METADATA +248 -0
  248. fleetpull-0.1.0.dist-info/RECORD +252 -0
  249. fleetpull-0.1.0.dist-info/WHEEL +5 -0
  250. fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
  251. fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
  252. 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)]