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,347 @@
1
+ # src/fleetpull/orchestrator/entry.py
2
+ """The orchestration entry: declarations in, run outcome out.
3
+
4
+ ``run_endpoint`` is the caller boundary one layer above ``EndpointRunner``: it
5
+ resolves the endpoint's declared request driver and runs. The governing
6
+ principle: higher-level orchestrators and tools are polymorphic --
7
+ provider-agnostic and endpoint-agnostic. A caller invoking an endpoint never
8
+ knows or branches on the provider, its request shape, its sync mode, its
9
+ storage cell, or its record identity; every dispatch keys off
10
+ ``EndpointDefinition`` declarations (the ``RequestShape`` union and
11
+ ``select_writer`` already state this for their seams -- this module extends
12
+ it to driver resolution). The shape-to-driver dispatch itself lives on the
13
+ shared seam (``shape_resolution.resolve_request_driver``, which ``fetch``
14
+ also calls); what this entry owns is the roster half it feeds that seam: a
15
+ roster-backed shape -- ``RosterFanOut``, or the ``BatchedRosterFanOut``
16
+ that packs one into comma-joined batches -- resolves its ``RosterKey``
17
+ through the ``RosterRegistry``,
18
+ refreshes the membership via the coordinator -- which owns the entire
19
+ staleness policy, including best-effort degradation and the loud cold-start
20
+ failure; the entry never reasons about freshness -- then reads the members
21
+ from the store. An empty roster after the refresh raises
22
+ ``ConfigurationError``, error-by-default (DESIGN section 13): a feeder that
23
+ listed nothing is a failure to surface, not an empty dataset to emit, and the
24
+ short-circuit keeps the writer's write-called-at-least-once precondition
25
+ intact (an ``allow_empty_roster`` escape joins ``RosterFanOut`` only when an
26
+ endpoint genuinely needs one). Every non-roster-backed shape touches no
27
+ roster machinery at all (unless the endpoint sources a roster -- below).
28
+
29
+ The entry also owns the feeder tap (the other half of the coupling rule:
30
+ every execution of a feeder endpoint updates its rosters and records a run;
31
+ parquet is written only when the user asked for that endpoint). It reverse-
32
+ looks-up the rosters the definition sources (``RosterRegistry.sourced_by``);
33
+ when any exist, it installs a generic batch observer that collects each
34
+ sourced roster's distinct ``source_column`` values -- values only, never
35
+ frames, so memory stays bounded by the distinct-key count -- and, after the
36
+ run returns ``Executed``, hands each collected listing to the coordinator's
37
+ ``apply_listing``, whose reconcile guard rejects an empty listing loudly (a
38
+ roster is never reconciled to empty; the failure propagates and fails the
39
+ endpoint while the prior membership stands). On a failed run nothing is
40
+ applied; on ``CaughtUp`` nothing executed, so there is no listing to apply.
41
+ A sourced definition that is not snapshot-mode is rejected before anything
42
+ runs -- ``reconcile`` is only correct over a complete listing, which only a
43
+ snapshot-mode feeder produces, so a watermark-mode source is a wiring bug
44
+ (the same guard the refresh coordinator applies on its harvest route). The
45
+ runner stays roster-blind: it sees only the generic observer.
46
+
47
+ Stateful collaborators are narrow Protocols (``EndpointExecutor``,
48
+ ``RosterRefresher``, ``RosterMembersReader``, and the seam's
49
+ ``FetchPoolSource``) the composition root satisfies with the real runner,
50
+ coordinator, store, and fetch-pool registry; the ``RosterRegistry`` is the
51
+ immutable catalog passed concrete (the run-executor precedent, DESIGN
52
+ section 14). The three roster collaborators always travel together, so they
53
+ ride as one ``RosterMachinery`` bundle (the bundle rule).
54
+ """
55
+
56
+ import logging
57
+ from collections.abc import Sequence
58
+ from dataclasses import dataclass
59
+ from functools import partial
60
+ from typing import Protocol
61
+
62
+ import polars as pl
63
+
64
+ from fleetpull.endpoints.shared import EndpointDefinition
65
+ from fleetpull.exceptions import ConfigurationError
66
+ from fleetpull.model_contract import ResponseModel
67
+ from fleetpull.orchestrator.drivers import RequestDriver
68
+ from fleetpull.orchestrator.outcome import CaughtUp, Executed, RunOutcome
69
+ from fleetpull.orchestrator.roster_harvest import require_snapshot_feeder
70
+ from fleetpull.orchestrator.shape_resolution import (
71
+ FetchPoolSource,
72
+ resolve_request_driver,
73
+ )
74
+ from fleetpull.orchestrator.streaming import BatchObserver
75
+ from fleetpull.records import extract_roster_members
76
+ from fleetpull.roster import RosterDefinition, RosterKey, RosterRegistry
77
+
78
+ __all__: list[str] = [
79
+ 'EndpointExecutor',
80
+ 'RosterMachinery',
81
+ 'RosterMembersReader',
82
+ 'RosterRefresher',
83
+ 'run_endpoint',
84
+ ]
85
+
86
+ logger = logging.getLogger(__name__)
87
+
88
+
89
+ class EndpointExecutor(Protocol):
90
+ """The run surface the entry needs (a subset of ``EndpointRunner``)."""
91
+
92
+ def run(
93
+ self,
94
+ definition: EndpointDefinition[ResponseModel],
95
+ driver: RequestDriver,
96
+ observer: BatchObserver | None = None,
97
+ ) -> RunOutcome:
98
+ """Run one endpoint to completion with the resolved driver."""
99
+ ...
100
+
101
+
102
+ class RosterRefresher(Protocol):
103
+ """The roster-policy surface the entry needs (``RosterRefreshCoordinator``'s shape)."""
104
+
105
+ def refresh_if_stale(self, definition: RosterDefinition) -> None:
106
+ """Re-list the roster's feeder and reconcile the membership if stale."""
107
+ ...
108
+
109
+ def apply_listing(self, definition: RosterDefinition, listed: set[str]) -> None:
110
+ """Reconcile an executed feeder run's listed membership into the store."""
111
+ ...
112
+
113
+
114
+ class RosterMembersReader(Protocol):
115
+ """The member-read surface the entry needs (a subset of ``RosterStore``)."""
116
+
117
+ def read_members(self, key: RosterKey) -> list[str]:
118
+ """Return the roster's members, ascending; empty when none stored."""
119
+ ...
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class RosterMachinery:
124
+ """The three roster collaborators a run consults, bundled.
125
+
126
+ They always travel together -- the fan-out resolution reads all three and
127
+ the feeder tap reads the catalog -- so they ride as one parameter (the
128
+ bundle rule). The composition root builds it once per run from the
129
+ discovered catalog, the refresh coordinator, and the roster store.
130
+
131
+ Attributes:
132
+ registry: The immutable roster catalog -- forward lookup for a
133
+ fan-out binding, reverse lookup for the rosters a definition
134
+ sources.
135
+ refresher: The roster-policy coordinator; owns staleness,
136
+ degradation, cold-start, and reconciliation whole.
137
+ members: The stored-membership read surface.
138
+ """
139
+
140
+ registry: RosterRegistry
141
+ refresher: RosterRefresher
142
+ members: RosterMembersReader
143
+
144
+
145
+ class _RosterMemberCollector:
146
+ """Collects each sourced roster's distinct members from observed batches.
147
+
148
+ The feeder tap's accumulator: for every roster the running endpoint
149
+ sources, it extracts the distinct ``source_column`` values from each
150
+ post-validation frame the runner hands the observer. Values only, never
151
+ frames -- memory stays bounded by the distinct-key count, not the run's
152
+ row count.
153
+ """
154
+
155
+ def __init__(self, sourced: Sequence[RosterDefinition]) -> None:
156
+ self._sourced = sourced
157
+ self._members: dict[RosterKey, set[str]] = {
158
+ definition.key: set() for definition in sourced
159
+ }
160
+
161
+ def observe(self, frame: pl.DataFrame) -> None:
162
+ """Fold one post-validation frame's members into each sourced roster.
163
+
164
+ Args:
165
+ frame: A validated, flattened batch frame from the run.
166
+
167
+ Raises:
168
+ ValueError: A sourced ``source_column`` is absent from the frame
169
+ (from ``extract_roster_members``) -- a wiring bug that fails
170
+ the run loudly. Null and empty-string values do not raise; the
171
+ extractor filters them loudly.
172
+ """
173
+ for definition in self._sourced:
174
+ self._members[definition.key] |= extract_roster_members(
175
+ frame, definition.source_column
176
+ )
177
+
178
+ def members_for(self, key: RosterKey) -> set[str]:
179
+ """Return the collected membership for one sourced roster."""
180
+ return self._members[key]
181
+
182
+
183
+ def run_endpoint(
184
+ definition: EndpointDefinition[ResponseModel],
185
+ runner: EndpointExecutor,
186
+ rosters: RosterMachinery,
187
+ fetch_pools: FetchPoolSource,
188
+ ) -> RunOutcome:
189
+ """Run one endpoint through its declared request driver, tapping feeder runs.
190
+
191
+ The provider- and endpoint-agnostic entry: dispatch keys off the
192
+ definition's declared ``request_shape`` (via the shared shape-resolution
193
+ seam) and the roster catalog only. The roster-backed shapes
194
+ (``RosterFanOut`` and ``BatchedRosterFanOut``) are fed a refreshed
195
+ roster membership and fanned out over the provider's fetch pool;
196
+ every other shape resolves with no roster machinery touched. When
197
+ the endpoint sources any roster (``sourced_by``), the run is observed
198
+ and -- on ``Executed`` -- each sourced roster is reconciled from the run's
199
+ collected members, so every feeder execution updates its rosters (the
200
+ coupling rule; the run row comes from the runner, and parquet was written
201
+ because the user asked for this endpoint). An endpoint that neither fans
202
+ out over a roster nor sources one touches no roster machinery at all.
203
+
204
+ Args:
205
+ definition: The endpoint to run, already resolved by the caller.
206
+ runner: The run executor (``EndpointRunner``'s ``run`` surface).
207
+ rosters: The roster catalog, policy coordinator, and stored-membership
208
+ read, bundled.
209
+ fetch_pools: The per-provider fetch pools (``FetchPoolRegistry``'s
210
+ ``pool_for`` surface); consulted only on the fanned shapes.
211
+
212
+ Returns:
213
+ The run outcome (``Executed`` or ``CaughtUp``), unchanged from the
214
+ runner.
215
+
216
+ Raises:
217
+ ConfigurationError: The shape names an unregistered roster; the
218
+ roster is empty after the refresh (error-by-default -- a feeder
219
+ that listed nothing is a failure to surface); or the definition
220
+ sources a roster but is not snapshot-mode (the feeder-mode guard:
221
+ reconcile is only correct over a complete listing, which only a
222
+ snapshot feeder produces).
223
+ FleetpullError: A cold-start roster refresh failed (propagated from
224
+ the coordinator), or the run itself failed (propagated from the
225
+ runner; a failed run applies nothing to any roster).
226
+
227
+ Side Effects:
228
+ Whatever the refresh and the run perform: network fetches, parquet
229
+ writes, and state-store commits by the injected collaborators; on a
230
+ successful feeder run, the sourced rosters' reconciled deltas.
231
+ """
232
+ sourced = rosters.registry.sourced_by(definition.provider, definition.name)
233
+ if sourced:
234
+ # The shared guard covers both reconcile routes: here the tap route,
235
+ # and the refresh coordinator's harvest route. A wiring bug, not a
236
+ # degradable condition.
237
+ require_snapshot_feeder(definition, definition.name)
238
+ driver = _resolve_driver(definition, rosters, fetch_pools)
239
+ if not sourced:
240
+ return runner.run(definition, driver)
241
+ collector = _RosterMemberCollector(sourced)
242
+ outcome = runner.run(definition, driver, collector.observe)
243
+ match outcome:
244
+ case Executed():
245
+ for roster_definition in sourced:
246
+ rosters.refresher.apply_listing(
247
+ roster_definition, collector.members_for(roster_definition.key)
248
+ )
249
+ case CaughtUp():
250
+ # Nothing executed, so nothing was listed -- an unexecuted run
251
+ # must not reconcile (an empty "listing" would count absences).
252
+ pass
253
+ return outcome
254
+
255
+
256
+ def _resolve_driver(
257
+ definition: EndpointDefinition[ResponseModel],
258
+ rosters: RosterMachinery,
259
+ fetch_pools: FetchPoolSource,
260
+ ) -> RequestDriver:
261
+ """Resolve the definition's declared request shape into a request driver.
262
+
263
+ A thin composition over the shared seam
264
+ (``shape_resolution.resolve_request_driver``): the shape-to-driver
265
+ dispatch is the seam's; what the entry contributes is the roster member
266
+ source -- the stateful half only this composition has. Module-private by
267
+ design: the shape distinctions are the entry's to hide, not the caller's
268
+ to compose with.
269
+
270
+ Args:
271
+ definition: The endpoint whose ``request_shape`` routes.
272
+ rosters: Resolves a ``RosterFanOut``'s ``RosterKey``, refreshes the
273
+ membership when stale, and reads it back.
274
+ fetch_pools: Supplies the provider's fetch pool for the fanned shapes.
275
+
276
+ Returns:
277
+ The seam's resolved driver.
278
+
279
+ Raises:
280
+ ConfigurationError: The shape names an unregistered roster (from
281
+ the registry), or the roster is empty after the refresh.
282
+ FleetpullError: A cold-start refresh failure, propagated unswallowed
283
+ from the coordinator.
284
+
285
+ Side Effects:
286
+ On the roster fan-out path: whatever the refresh performs (a feeder
287
+ listing and a store write when stale).
288
+ """
289
+ return resolve_request_driver(
290
+ definition,
291
+ fetch_pools=fetch_pools,
292
+ roster_members=partial(_refreshed_roster_members, definition, rosters),
293
+ )
294
+
295
+
296
+ def _refreshed_roster_members(
297
+ definition: EndpointDefinition[ResponseModel],
298
+ rosters: RosterMachinery,
299
+ roster: RosterKey,
300
+ ) -> Sequence[str]:
301
+ """Resolve a named roster's refreshed membership -- the seam's feed.
302
+
303
+ The roster machinery whole: registry lookup, the coordinator's refresh
304
+ (staleness policy included), the store read, and the empty-roster guard.
305
+
306
+ Args:
307
+ definition: The endpoint being run, for the error context.
308
+ rosters: The roster catalog, policy coordinator, and stored-membership
309
+ read, bundled.
310
+ roster: The roster the declared shape names.
311
+
312
+ Returns:
313
+ The roster's members, ascending, never empty.
314
+
315
+ Raises:
316
+ ConfigurationError: The shape names an unregistered roster (from the
317
+ registry), or the roster is empty after the refresh
318
+ (error-by-default -- a feeder that listed nothing is a failure to
319
+ surface, not an empty dataset to emit).
320
+ FleetpullError: A cold-start refresh failure, propagated unswallowed
321
+ from the coordinator.
322
+
323
+ Side Effects:
324
+ Whatever the refresh performs (a feeder listing and a store write
325
+ when stale).
326
+ """
327
+ roster_definition = rosters.registry.get(roster)
328
+ rosters.refresher.refresh_if_stale(roster_definition)
329
+ members = rosters.members.read_members(roster)
330
+ if not members:
331
+ raise ConfigurationError(
332
+ 'fan-out roster is empty',
333
+ provider=definition.provider.value,
334
+ endpoint=definition.name,
335
+ detail=(
336
+ f'roster {roster.name!r} holds no members after refresh; '
337
+ f'a fan-out over nothing is a failure to surface, not an empty '
338
+ f'dataset to emit'
339
+ ),
340
+ )
341
+ logger.debug(
342
+ 'fan-out resolved: endpoint=%s roster=%s members=%d',
343
+ definition.name,
344
+ roster.name,
345
+ len(members),
346
+ )
347
+ return members
@@ -0,0 +1,128 @@
1
+ # src/fleetpull/orchestrator/executors.py
2
+ """Provider-keyed registry of fetch pools: one worker pool per provider.
3
+
4
+ The per-provider executor of DESIGN §7, owned by ``Sync``'s composition root
5
+ as a context-managed collaborator (the ``ProviderClientRegistry`` precedent):
6
+ ``__enter__`` creates one ``ThreadPoolExecutor`` per configured provider,
7
+ sized ``max_workers = rate_limit.max_concurrency``, and ``__exit__`` shuts
8
+ every pool down deterministically -- success or failure, no leaked threads.
9
+ The lifecycle machinery (publish-on-success enter, closed-before-release
10
+ exit, the RuntimeError-vs-ConfigurationError lookup split) is the generic
11
+ ``ProviderResourceRegistry``'s (the network client face); this subclass
12
+ supplies pool construction and the error nouns.
13
+ Per-provider pools by construction, so one provider's 429 penalty can never
14
+ park another provider's workers (the starvation fix), and a fan-out run's
15
+ in-flight ceiling equals the limiter's concurrency semaphore, which the pool
16
+ never replaces -- workers acquire tokens and the semaphore exactly as the
17
+ serial path did.
18
+
19
+ ``pool_for`` hands out the provider's ``FetchPool`` -- the executor plus the
20
+ bounded-channel window ``stream_pieces`` enforces. The window is fixed here,
21
+ at the one place the worker count is known, to twice that count: a finishing
22
+ worker always has a queued piece to pick up while the consumer writes, and
23
+ the fetched-but-unconsumed bound stays a small multiple of the pool size.
24
+
25
+ Since the intra-provider grain (DESIGN §7, 2026-07-20) one pool may serve
26
+ multiple concurrent fan-out endpoints: a submission window is
27
+ per-``stream_pieces``-call local state, so each concurrent stream keeps its
28
+ own window over the shared executor -- execution stays capped by
29
+ ``max_workers`` and in-flight requests by the limiter's semaphore, so the
30
+ in-flight ceiling claim above holds unchanged and nothing here resized.
31
+ """
32
+
33
+ from collections.abc import Mapping
34
+ from concurrent.futures import ThreadPoolExecutor
35
+ from contextlib import ExitStack
36
+ from typing import ClassVar
37
+
38
+ from fleetpull.network.client import ProviderResourceRegistry
39
+ from fleetpull.orchestrator.fanout import FetchPool
40
+ from fleetpull.vocabulary import Provider
41
+
42
+ __all__: list[str] = ['FetchPoolRegistry']
43
+
44
+ # The submission window as a multiple of the worker count: 2x keeps a queued
45
+ # piece ready for every finishing worker (plain double-buffering) while the
46
+ # streaming bound stays proportional to the pool, never the roster.
47
+ _SUBMISSION_WINDOW_FACTOR: int = 2
48
+
49
+
50
+ class FetchPoolRegistry(ProviderResourceRegistry[FetchPool]):
51
+ """Owns one fetch worker pool per provider, keyed by ``Provider``.
52
+
53
+ A resource-owning context manager (the generic base's semantics):
54
+ ``__exit__`` shuts each pool down with ``wait=True``, so no worker thread
55
+ outlives the run that composed it, on success and on failure alike.
56
+ ``pool_for`` returns a provider's ``FetchPool``; use it only inside the
57
+ ``with`` block::
58
+
59
+ with FetchPoolRegistry(workers_by_provider) as fetch_pools:
60
+ pool = fetch_pools.pool_for(definition.provider)
61
+ """
62
+
63
+ _resource_noun: ClassVar[str] = 'fetch pool'
64
+ _lookup_description: ClassVar[str] = 'pool_for'
65
+
66
+ def __init__(self, workers_by_provider: Mapping[Provider, int]) -> None:
67
+ """
68
+ Args:
69
+ workers_by_provider: Each configured provider's worker count --
70
+ ``rate_limit.max_concurrency`` from that provider's resolved
71
+ config, so the pool can never outrun the limiter's in-flight
72
+ semaphore. A provider absent here has no pool and is rejected
73
+ by ``pool_for`` while the registry is open.
74
+
75
+ Side Effects:
76
+ None -- pools are created on ``__enter__``, not here.
77
+ """
78
+ super().__init__(workers_by_provider)
79
+ self._workers_by_provider: dict[Provider, int] = dict(workers_by_provider)
80
+
81
+ def _open_resource(self, stack: ExitStack, provider: Provider) -> FetchPool:
82
+ """Create one provider's worker pool with its fixed submission window.
83
+
84
+ ``ThreadPoolExecutor.__exit__`` is ``shutdown(wait=True)``: every
85
+ worker thread is joined when the stack unwinds, so a failing run
86
+ cannot leak threads into the caller.
87
+
88
+ Args:
89
+ stack: The enter's unwind stack; the pool's shutdown registers
90
+ here.
91
+ provider: The provider whose pool to create.
92
+
93
+ Returns:
94
+ The provider's ``FetchPool``.
95
+
96
+ Side Effects:
97
+ Constructs one ``ThreadPoolExecutor`` (threads spawn lazily on
98
+ first submit).
99
+ """
100
+ worker_count = self._workers_by_provider[provider]
101
+ executor = stack.enter_context(
102
+ ThreadPoolExecutor(
103
+ max_workers=worker_count,
104
+ thread_name_prefix=f'fleetpull-{provider.value}-fetch',
105
+ )
106
+ )
107
+ return FetchPool(
108
+ executor=executor,
109
+ submission_window=_SUBMISSION_WINDOW_FACTOR * worker_count,
110
+ )
111
+
112
+ def pool_for(self, provider: Provider) -> FetchPool:
113
+ """Return the fetch pool for a provider.
114
+
115
+ Args:
116
+ provider: The provider whose pool to return (e.g.
117
+ ``definition.provider``).
118
+
119
+ Returns:
120
+ The provider's ``FetchPool``.
121
+
122
+ Raises:
123
+ RuntimeError: The registry is not open -- ``pool_for`` was called
124
+ outside an active ``with`` block (a caller bug).
125
+ ConfigurationError: The registry is open but the provider has no
126
+ configured pool.
127
+ """
128
+ return self._resource_for(provider)
@@ -0,0 +1,163 @@
1
+ # src/fleetpull/orchestrator/fanout.py
2
+ """The bounded fan-out channel: worker-fetched pieces, streamed to one consumer.
3
+
4
+ ``stream_pieces`` is the concurrency spine of a fan-out run (DESIGN §7): the
5
+ caller supplies one task per (member x window) piece, workers on the injected
6
+ executor fetch whole pieces, and the consumer thread -- the only thread that
7
+ ever validates, frames, or writes -- receives each piece's items as a lazy
8
+ stream. The channel is a bounded submission window over futures, drained in
9
+ submission order:
10
+
11
+ - **Bounded, never collected.** At most ``pool.submission_window`` pieces are
12
+ outstanding, plus the one being yielded -- the fetched-but-unconsumed count
13
+ never exceeds ``submission_window + 1``, a function of the pool size, never
14
+ of the member count. When the consumer lags, no new piece is submitted, so
15
+ workers idle instead of burning rate-budget tokens on results that cannot
16
+ yet be written.
17
+ - **Submission-order draining.** The consumer waits on the oldest outstanding
18
+ future, so items yield in exactly the order a serial loop would produce
19
+ them -- correctness never depends on which piece completes first, and a
20
+ synchronous same-thread executor reproduces the serial path verbatim.
21
+ - **First failure wins.** The first exception the consumer encounters
22
+ re-raises after the window unwinds: not-yet-started pieces are cancelled
23
+ (unsubmitted members are never submitted at all), in-flight pieces finish
24
+ and their results are discarded, and any discarded piece's own failure is
25
+ logged, never raised over the first.
26
+
27
+ Workers touch the network and this channel, nothing else; the single-writer
28
+ invariant (DESIGN §3) is the consumer's side of the contract, not enforced
29
+ here. The limiter remains the only enforcement point on the wire -- the pool
30
+ merely supplies workers, each of which acquires tokens and the concurrency
31
+ semaphore exactly as the serial path does.
32
+ """
33
+
34
+ import logging
35
+ from collections.abc import Callable, Generator, Iterable, Sequence
36
+ from concurrent.futures import Executor, Future
37
+ from dataclasses import dataclass
38
+
39
+ __all__: list[str] = ['FetchPool', 'stream_pieces']
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class FetchPool:
46
+ """One provider's fetch workers plus the channel bound they imply.
47
+
48
+ The pair travels together because the bound is a property of the pool:
49
+ ``Executor`` does not expose its worker count, so the composition root
50
+ that sizes the executor also fixes the submission window (the registry
51
+ sets it to twice the worker count -- workers always have a queued piece
52
+ to pick up while the consumer writes). Tests inject a synchronous
53
+ same-thread executor through this same seam.
54
+
55
+ Attributes:
56
+ executor: The worker supply for piece fetches. Lifecycle belongs to
57
+ whoever composed it (``FetchPoolRegistry`` in production) --
58
+ ``stream_pieces`` never shuts it down.
59
+ submission_window: Maximum pieces outstanding at once (>= 1). The
60
+ streaming bound is ``submission_window + 1`` fetched-but-unconsumed
61
+ pieces (the window plus the piece being yielded).
62
+ """
63
+
64
+ executor: Executor
65
+ submission_window: int
66
+
67
+ def __post_init__(self) -> None:
68
+ """Reject a window that could never stream.
69
+
70
+ Raises:
71
+ ValueError: ``submission_window`` is less than 1 -- a zero window
72
+ would prime nothing and silently yield an empty run.
73
+ """
74
+ if self.submission_window < 1:
75
+ raise ValueError(
76
+ f'submission_window must be >= 1, got {self.submission_window}'
77
+ )
78
+
79
+
80
+ def _discard_outstanding[PieceItem](
81
+ outstanding: Iterable[Future[Sequence[PieceItem]]],
82
+ ) -> None:
83
+ """Unwind the window: cancel what has not started, discard what has.
84
+
85
+ Cancels every outstanding future (a no-op for ones already running),
86
+ waits for the in-flight ones to finish, and logs -- never raises -- any
87
+ failure among the discarded results, so a winning first exception (or the
88
+ consumer's own abandonment) is never masked by a straggler's.
89
+
90
+ Args:
91
+ outstanding: The window's remaining futures, oldest first.
92
+
93
+ Side Effects:
94
+ Blocks until every in-flight piece finishes; logs each discarded
95
+ piece's exception at ERROR.
96
+ """
97
+ for future in outstanding:
98
+ future.cancel()
99
+ for future in outstanding:
100
+ if future.cancelled():
101
+ continue
102
+ discarded_error = future.exception()
103
+ if discarded_error is not None:
104
+ logger.error(
105
+ 'discarding an in-flight fan-out piece that failed after the '
106
+ 'run was already unwinding',
107
+ exc_info=discarded_error,
108
+ )
109
+
110
+
111
+ def stream_pieces[PieceItem](
112
+ piece_tasks: Iterable[Callable[[], Sequence[PieceItem]]],
113
+ pool: FetchPool,
114
+ ) -> Generator[PieceItem, None, None]:
115
+ """Fetch pieces on the pool's workers; stream their items in task order.
116
+
117
+ Primes the window with the first ``pool.submission_window`` tasks, then
118
+ repeatedly: wait on the oldest future, submit the next task (so workers
119
+ stay busy while the consumer processes), and yield the finished piece's
120
+ items. Tasks past the window are submitted only as earlier pieces are
121
+ consumed -- the backpressure that keeps fetched-but-unconsumed results at
122
+ ``submission_window + 1`` pieces, never a function of task count.
123
+
124
+ Args:
125
+ piece_tasks: One callable per piece, each returning that piece's
126
+ items; consumed lazily, in order. Each runs on a worker thread
127
+ and must touch only what is safe there (the network, for a
128
+ fan-out fetch).
129
+ pool: The workers and the window bound, as one collaborator.
130
+
131
+ Yields:
132
+ Every piece's items, piece by piece, in task order -- the order a
133
+ serial loop over ``piece_tasks`` would produce.
134
+
135
+ Raises:
136
+ Exception: The first failure among the pieces, re-raised unchanged
137
+ after the window unwinds (later pieces cancelled or discarded;
138
+ a discarded piece's own failure is logged, never raised).
139
+
140
+ Side Effects:
141
+ Submits work to ``pool.executor``; on failure or an abandoning
142
+ consumer (``close()``), cancels and drains the window before
143
+ returning. Never shuts the executor down.
144
+ """
145
+ tasks = iter(piece_tasks)
146
+ window: list[Future[Sequence[PieceItem]]] = []
147
+ try:
148
+ for task in tasks:
149
+ window.append(pool.executor.submit(task))
150
+ if len(window) >= pool.submission_window:
151
+ break
152
+ while window:
153
+ piece = window.pop(0).result()
154
+ next_task = next(tasks, None)
155
+ if next_task is not None:
156
+ window.append(pool.executor.submit(next_task))
157
+ yield from piece
158
+ finally:
159
+ # Reached with a non-empty window only when the stream is dying: a
160
+ # piece's exception is propagating, or the consumer closed the
161
+ # generator mid-stream. Either way the window unwinds before the
162
+ # cause continues outward.
163
+ _discard_outstanding(window)