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,262 @@
1
+ # src/fleetpull/orchestrator/runner.py
2
+ """The run executor: run one endpoint to completion, once per (endpoint, run).
3
+
4
+ ``EndpointRunner`` owns one endpoint's run and dispatches on its ``sync_mode``.
5
+ The snapshot arm lives here: fetch once, full-replace, record the run. The
6
+ watermark arm is the ``WatermarkDrive`` (``orchestrator/watermark_drive.py``:
7
+ the plan-and-drive unit loop with the prefix-advance watermark rule) and the
8
+ feed arm is the ``FeedDrive`` (``orchestrator/feed_drive.py``: the per-page
9
+ parquet-before-token drive) -- both constructed in ``__init__`` from the same
10
+ ``RunnerSpine`` (``orchestrator/spine.py``): the four collaborators plus the
11
+ runner-owned seams, the ONE writer-factory call site (``_writer_for``) and
12
+ the post-commit ``metadata.json`` projection
13
+ (``orchestrator/metadata_projection.py``). Every run-opening arm wraps its
14
+ protected block in the shared ``recorded_run`` spine
15
+ (``orchestrator/recording.py``), so a failure records the run failed without
16
+ masking the original error. Request cardinality and batch granularity are
17
+ the driver's; the runner is blind to both.
18
+
19
+ ``run`` takes an optional ``BatchObserver``: a generic hook handed each
20
+ post-validation frame as the run streams. The runner knows nothing about what
21
+ an observer does with the frames (the caller boundary uses it to tap feeder
22
+ runs for roster reconciliation, but that knowledge lives entirely there) -- an
23
+ observer exception fails the run like any other batch-processing failure.
24
+
25
+ The runner depends on narrow Protocols rather than the concrete state and
26
+ network classes -- ``ClientSource``, ``RunRecorder``, ``CursorAccess``, and
27
+ ``UnitQueue``, bundled as ``RunStateAccess`` (all on the spine module). It
28
+ opens no clients and reads no credentials -- the already-open client source
29
+ hands it the provider's client.
30
+ """
31
+
32
+ import logging
33
+
34
+ from fleetpull.config import FleetpullConfig
35
+ from fleetpull.endpoints.shared import (
36
+ EndpointDefinition,
37
+ FeedMode,
38
+ SnapshotMode,
39
+ WatermarkMode,
40
+ )
41
+ from fleetpull.incremental import DateWindow
42
+ from fleetpull.model_contract import ResponseModel
43
+ from fleetpull.orchestrator.drivers import RequestDriver
44
+ from fleetpull.orchestrator.feed_drive import FeedDrive
45
+ from fleetpull.orchestrator.metadata_projection import (
46
+ MetadataProjection,
47
+ sync_mode_label,
48
+ )
49
+ from fleetpull.orchestrator.outcome import Executed, RunOutcome
50
+ from fleetpull.orchestrator.recording import recorded_run
51
+ from fleetpull.orchestrator.spine import ClientSource, RunnerSpine, RunStateAccess
52
+ from fleetpull.orchestrator.streaming import (
53
+ BatchObserver,
54
+ drain_batches,
55
+ observe_batches,
56
+ stream_processed_batches,
57
+ )
58
+ from fleetpull.orchestrator.watermark_drive import WatermarkDrive
59
+ from fleetpull.storage import DatasetWriter, select_writer
60
+ from fleetpull.timing import Clock
61
+
62
+ __all__: list[str] = ['EndpointRunner']
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ class EndpointRunner:
68
+ """Runs one endpoint to completion, dispatching on its sync mode.
69
+
70
+ Constructed once with its four collaborators (client source, the bundled
71
+ state surfaces, clock, the root config); ``run`` takes the endpoint and
72
+ its request driver, so one instance runs every endpoint. The snapshot
73
+ arm lives on the class; the watermark and feed arms are the two drive
74
+ classes built in ``__init__`` from the shared spine.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ client_source: ClientSource,
80
+ state: RunStateAccess,
81
+ clock: Clock,
82
+ config: FleetpullConfig,
83
+ ) -> None:
84
+ """
85
+ Args:
86
+ client_source: Hands out an open per-provider client (the registry).
87
+ state: The state-database surfaces -- the ledger, the cursor
88
+ store, and the work-unit queue.
89
+ clock: Supplies the run instant (trailing edge, future-event guard).
90
+ config: The root config -- the container its composition root
91
+ already holds. The runner reads the ``sync`` section (the
92
+ cold-start anchor, the unit width, and the unit worker
93
+ count, handed to the drives on the spine) plus two storage
94
+ values: ``storage.dataset_root`` (where the writers land)
95
+ and ``storage.drop_exact_duplicates`` (the writers'
96
+ exact-dedup switch).
97
+ """
98
+ self._dataset_root = config.storage.dataset_root
99
+ self._drop_duplicates = config.storage.drop_exact_duplicates
100
+ self._spine = RunnerSpine(
101
+ clients=client_source,
102
+ state=state,
103
+ clock=clock,
104
+ sync=config.sync,
105
+ make_writer=self._writer_for,
106
+ projection=MetadataProjection(
107
+ state.cursors, clock, config.storage.dataset_root
108
+ ),
109
+ )
110
+ self._watermark_drive = WatermarkDrive(self._spine)
111
+ self._feed_drive = FeedDrive(self._spine)
112
+
113
+ def run(
114
+ self,
115
+ definition: EndpointDefinition[ResponseModel],
116
+ driver: RequestDriver,
117
+ observer: BatchObserver | None = None,
118
+ ) -> RunOutcome:
119
+ """Run one endpoint to completion and report the outcome.
120
+
121
+ Narrates at INFO: one start line at entry (provider, endpoint, sync
122
+ mode) and, for an ``Executed`` outcome, one completion line with the
123
+ merged counts and the endpoint's elapsed seconds (a monotonic delta
124
+ captured at entry). A ``CaughtUp`` outcome narrates through the
125
+ watermark arm's own 'caught up' line instead.
126
+
127
+ Args:
128
+ definition: The endpoint to run.
129
+ driver: The request driver supplying the run's record batches.
130
+ observer: An optional generic hook handed each post-validation
131
+ frame as the run streams; the runner is blind to what it does.
132
+
133
+ Returns:
134
+ The run outcome -- ``Executed``, or ``CaughtUp`` when a windowed
135
+ run had nothing to drive.
136
+
137
+ Raises:
138
+ FleetpullError: A fetch, validation, or write failure -- the run is
139
+ recorded failed and the error propagates.
140
+ """
141
+ endpoint_started = self._spine.clock.monotonic_seconds()
142
+ logger.info(
143
+ 'endpoint started: provider=%s endpoint=%s mode=%s',
144
+ definition.provider.value,
145
+ definition.name,
146
+ sync_mode_label(definition.sync_mode),
147
+ )
148
+ match definition.sync_mode:
149
+ case SnapshotMode():
150
+ outcome: RunOutcome = self._run_snapshot(definition, driver, observer)
151
+ case WatermarkMode() as mode:
152
+ outcome = self._watermark_drive.run(definition, driver, mode, observer)
153
+ case FeedMode():
154
+ outcome = self._feed_drive.run(definition, driver, observer)
155
+ if isinstance(outcome, Executed):
156
+ self._log_endpoint_complete(definition, outcome, endpoint_started)
157
+ return outcome
158
+
159
+ def _writer_for(
160
+ self,
161
+ definition: EndpointDefinition[ResponseModel],
162
+ window: DateWindow | None = None,
163
+ ) -> DatasetWriter:
164
+ """Construct the endpoint's writer -- the ONE ``select_writer`` call site.
165
+
166
+ Every arm's writer routes through here (the drives receive it as the
167
+ spine's ``make_writer``), with the dataset root and the exact-dedup
168
+ switch bound once at construction.
169
+
170
+ Args:
171
+ definition: The endpoint being run.
172
+ window: The run's resolved resume window, for the incremental
173
+ cells; ``None`` for the snapshot and feed cells.
174
+
175
+ Returns:
176
+ The endpoint's ``DatasetWriter`` for this run.
177
+ """
178
+ return select_writer(
179
+ definition,
180
+ self._dataset_root,
181
+ window=window,
182
+ drop_duplicates=self._drop_duplicates,
183
+ )
184
+
185
+ def _run_snapshot(
186
+ self,
187
+ definition: EndpointDefinition[ResponseModel],
188
+ driver: RequestDriver,
189
+ observer: BatchObserver | None,
190
+ ) -> RunOutcome:
191
+ """Run the snapshot arm: full fetch, full-replace write, record the run.
192
+
193
+ Resolves the provider's client, opens a snapshot run, drives the batches,
194
+ writes each, and completes the run with the fetched-row count. A snapshot has
195
+ no resume value and no cursor, so it passes ``resume=None`` and advances no
196
+ watermark. The client is resolved before the run is opened, so an
197
+ unconfigured provider opens no dangling run. ``complete_run`` runs inside the
198
+ protected block: a failure to record completion marks the run failed rather
199
+ than leaving a zombie ``running`` row. The ``metadata.json`` projection
200
+ writes after the protected block -- the run is committed by then, and a
201
+ post-commit projection failure must never mark a succeeded run failed.
202
+
203
+ Args:
204
+ definition: The snapshot endpoint to run.
205
+ driver: The shape-resolved request driver -- a
206
+ ``SingleRequestDriver`` for a single-fetch snapshot, a
207
+ ``FanOutRequestDriver`` for a ``ParamSweep`` one.
208
+ observer: The optional per-frame hook, handed each post-validation
209
+ frame as the run streams.
210
+
211
+ Returns:
212
+ ``Executed`` with the fetched-row count and the write report.
213
+
214
+ Raises:
215
+ FleetpullError: A fetch, validation, write, or completion failure -- the
216
+ run is recorded failed and the original error re-raised.
217
+ """
218
+ recorder = self._spine.state.recorder
219
+ client = self._spine.clients.client_for(definition.provider)
220
+ run_id = recorder.start_snapshot_run(definition.provider, definition.name)
221
+ with recorded_run(recorder, run_id):
222
+ writer = self._writer_for(definition)
223
+ batches = stream_processed_batches(
224
+ definition, driver, client, resume=None, context=None
225
+ )
226
+ records_fetched, _ = drain_batches(
227
+ observe_batches(batches, observer), writer
228
+ )
229
+ write = writer.finalize()
230
+ recorder.complete_run(run_id, row_count=records_fetched)
231
+ outcome = Executed(records_fetched=records_fetched, write=write)
232
+ self._spine.projection.project(definition, outcome, window=None)
233
+ return outcome
234
+
235
+ def _log_endpoint_complete(
236
+ self,
237
+ definition: EndpointDefinition[ResponseModel],
238
+ outcome: Executed,
239
+ endpoint_started: float,
240
+ ) -> None:
241
+ """Narrate a committed endpoint's counts and elapsed time at INFO.
242
+
243
+ Args:
244
+ definition: The endpoint that just ran.
245
+ outcome: The run's (merged) ``Executed`` outcome.
246
+ endpoint_started: The ``monotonic_seconds`` reading captured at
247
+ ``run()`` entry, so the elapsed value covers the whole
248
+ endpoint (both arms, metadata projection included).
249
+ """
250
+ logger.info(
251
+ 'endpoint complete: provider=%s endpoint=%s records_fetched=%d '
252
+ 'rows_written=%d duplicates_dropped=%d files_written=%d '
253
+ 'deleted_partitions=%d elapsed_seconds=%.1f',
254
+ definition.provider.value,
255
+ definition.name,
256
+ outcome.records_fetched,
257
+ outcome.write.rows_written,
258
+ outcome.write.duplicates_dropped,
259
+ outcome.write.files_written,
260
+ len(outcome.write.deleted_partitions),
261
+ self._spine.clock.monotonic_seconds() - endpoint_started,
262
+ )
@@ -0,0 +1,221 @@
1
+ # src/fleetpull/orchestrator/shape_resolution.py
2
+ """The shared shape-to-driver seam: one ``RequestShape`` match, one driver out.
3
+
4
+ ``resolve_request_driver`` is the single place a declared ``request_shape``
5
+ becomes a ``RequestDriver`` -- both composition roots call it (the
6
+ orchestration entry for sync, ``fetch`` for the in-memory verb), so a new
7
+ cardinality pattern is a new union member plus its arm here, never a new
8
+ field or a new branch anywhere else. The seam owns only the dispatch:
9
+ supplying roster members for the roster-backed shapes (``RosterFanOut`` /
10
+ ``BatchedRosterFanOut``) -- registry lookup, refresh policy, store read,
11
+ the empty-roster guard -- stays with the caller, which feeds them in
12
+ through the ``RosterMemberSource`` callable. A stateless caller
13
+ (``fetch``) passes ``roster_members=None`` and every stateless shape
14
+ resolves; a roster-backed shape then fails loudly, because a roster is
15
+ durable operational state the stateless composition deliberately lacks.
16
+ """
17
+
18
+ from collections.abc import Callable, Sequence
19
+ from typing import Protocol
20
+
21
+ from fleetpull.endpoints.shared import (
22
+ BatchedRosterFanOut,
23
+ BisectedWindowFetch,
24
+ EndpointDefinition,
25
+ ParamSweep,
26
+ RosterFanOut,
27
+ SingleFetch,
28
+ )
29
+ from fleetpull.exceptions import ConfigurationError
30
+ from fleetpull.model_contract import ResponseModel
31
+ from fleetpull.orchestrator.bisection import BisectingWindowDriver
32
+ from fleetpull.orchestrator.drivers import (
33
+ FanOutRequestDriver,
34
+ RequestDriver,
35
+ SingleRequestDriver,
36
+ )
37
+ from fleetpull.orchestrator.fanout import FetchPool
38
+ from fleetpull.roster import RosterKey
39
+ from fleetpull.vocabulary import Provider
40
+
41
+ __all__: list[str] = [
42
+ 'FetchPoolSource',
43
+ 'RosterMemberSource',
44
+ 'resolve_request_driver',
45
+ ]
46
+
47
+
48
+ class FetchPoolSource(Protocol):
49
+ """The pool-lookup surface the resolution needs (``FetchPoolRegistry``'s shape)."""
50
+
51
+ def pool_for(self, provider: Provider) -> FetchPool:
52
+ """Return the fetch pool for a provider."""
53
+ ...
54
+
55
+
56
+ # The caller's roster half of a roster-backed resolution: handed the
57
+ # roster's key, it returns the refreshed membership (or raises the caller's
58
+ # own roster failure). Both roster-backed shapes name their roster with the
59
+ # same ``RosterKey``, so one key-shaped source serves them both -- one
60
+ # roster machinery path, no second seam, no synthetic shape. None marks a
61
+ # stateless caller with no roster state at all.
62
+ type RosterMemberSource = Callable[[RosterKey], Sequence[str]]
63
+
64
+
65
+ def resolve_request_driver(
66
+ definition: EndpointDefinition[ResponseModel],
67
+ *,
68
+ fetch_pools: FetchPoolSource,
69
+ roster_members: RosterMemberSource | None,
70
+ ) -> RequestDriver:
71
+ """Resolve a definition's declared request shape into a request driver.
72
+
73
+ Args:
74
+ definition: The endpoint whose ``request_shape`` routes.
75
+ fetch_pools: Supplies the provider's fetch pool for the fanned shapes
76
+ (``RosterFanOut`` / ``BatchedRosterFanOut`` / ``ParamSweep``);
77
+ never consulted for the single-chain shapes.
78
+ roster_members: The caller's roster membership source, invoked only
79
+ for the roster-backed shapes; ``None`` for a stateless caller
80
+ with no roster state.
81
+
82
+ Returns:
83
+ The ``SingleRequestDriver`` for ``SingleFetch``; the
84
+ ``BisectingWindowDriver`` for ``BisectedWindowFetch``; the
85
+ ``FanOutRequestDriver`` over the roster's members for
86
+ ``RosterFanOut``, over sorted comma-joined member batches for
87
+ ``BatchedRosterFanOut``, or over the declared values
88
+ (``member_key`` = ``param``) for ``ParamSweep`` -- the driver is
89
+ member-agnostic, so every fanned shape shares it.
90
+
91
+ Raises:
92
+ ConfigurationError: The shape is roster-backed (``RosterFanOut`` /
93
+ ``BatchedRosterFanOut``) and no roster source is available --
94
+ the stateless-caller case.
95
+ FleetpullError: Whatever the roster source raises resolving a
96
+ roster-backed shape (an unregistered or empty roster, a
97
+ cold-start refresh failure), propagated unswallowed.
98
+
99
+ Side Effects:
100
+ On the roster-backed paths: whatever the supplied source performs
101
+ (a feeder listing and a store write when stale).
102
+ """
103
+ match definition.request_shape:
104
+ case SingleFetch():
105
+ return SingleRequestDriver()
106
+ case BisectedWindowFetch() as shape:
107
+ return BisectingWindowDriver(shape=shape)
108
+ case ParamSweep() as sweep:
109
+ return FanOutRequestDriver(
110
+ members=sweep.values,
111
+ member_key=sweep.param,
112
+ fetch_pool=fetch_pools.pool_for(definition.provider),
113
+ )
114
+ case RosterFanOut() as fan_out:
115
+ return FanOutRequestDriver(
116
+ members=_require_roster_members(
117
+ definition, roster_members, fan_out.roster
118
+ ),
119
+ member_key=fan_out.member_key,
120
+ fetch_pool=fetch_pools.pool_for(definition.provider),
121
+ )
122
+ case BatchedRosterFanOut() as batched:
123
+ # The batched shape is transport packing over the plain roster
124
+ # fan-out, so membership resolves through the identical source
125
+ # call -- the same roster key -- and only then chunks into
126
+ # comma-joined batch values. The driver stays member-agnostic:
127
+ # each batch is simply one member string.
128
+ members = _require_roster_members(
129
+ definition, roster_members, batched.roster
130
+ )
131
+ return FanOutRequestDriver(
132
+ members=_comma_joined_batches(members, batched.batch_size),
133
+ member_key=batched.member_key,
134
+ fetch_pool=fetch_pools.pool_for(definition.provider),
135
+ )
136
+
137
+
138
+ def _require_roster_members(
139
+ definition: EndpointDefinition[ResponseModel],
140
+ roster_members: RosterMemberSource | None,
141
+ roster: RosterKey,
142
+ ) -> Sequence[str]:
143
+ """Resolve a roster-backed shape's membership, refusing a stateless caller.
144
+
145
+ Args:
146
+ definition: The endpoint being resolved, for the error context.
147
+ roster_members: The caller's roster membership source, or ``None``
148
+ for a stateless caller with no roster state.
149
+ roster: The roster the shape names -- a ``RosterFanOut``'s or a
150
+ ``BatchedRosterFanOut``'s ``roster`` key alike.
151
+
152
+ Returns:
153
+ The refreshed membership, exactly as the source supplies it.
154
+
155
+ Raises:
156
+ ConfigurationError: No roster source is available -- the
157
+ stateless-caller (in-memory ``fetch``) case.
158
+ FleetpullError: Whatever the source raises (an unregistered or
159
+ empty roster, a cold-start refresh failure), propagated
160
+ unswallowed.
161
+
162
+ Side Effects:
163
+ Whatever the supplied source performs (a feeder listing and a
164
+ store write when stale).
165
+ """
166
+ if roster_members is None:
167
+ raise ConfigurationError(
168
+ 'no roster source for a roster fan-out endpoint',
169
+ provider=definition.provider.value,
170
+ endpoint=definition.name,
171
+ detail=(
172
+ f'the {roster.name!r} roster fan-out needs '
173
+ f'durable roster state, which this stateless '
174
+ f'composition (the in-memory fetch verb) deliberately '
175
+ f'lacks; run it through the config-driven sync path'
176
+ ),
177
+ )
178
+ return roster_members(roster)
179
+
180
+
181
+ def _comma_joined_batches(members: Sequence[str], batch_size: int) -> tuple[str, ...]:
182
+ """Chunk members into sorted, comma-joined batch values, one per chain.
183
+
184
+ Deterministic by construction: members sort before chunking, so
185
+ identical rosters always produce identical batches regardless of the
186
+ source's ordering. Pure -- no state, no side effects; the batch is
187
+ transport packing only (records self-identify), so nothing here maps
188
+ a member back to its batch.
189
+
190
+ Args:
191
+ members: The roster's member values, in any order. A member
192
+ containing the join delimiter is rejected loudly: a comma
193
+ inside one member would silently widen the batch on the wire
194
+ (more ids than members -- past the API cap, or addressing an
195
+ unintended asset), and no provider's roster ids carry commas.
196
+ batch_size: The maximum members per batch; at least 1, enforced
197
+ by ``BatchedRosterFanOut`` at declaration.
198
+
199
+ Returns:
200
+ The comma-joined batch strings, in sorted-member order -- fewer
201
+ members than one batch yields a single batch.
202
+
203
+ Raises:
204
+ ConfigurationError: A member value contains a comma -- corrupt
205
+ roster data surfaced loudly, never packed onto the wire.
206
+ """
207
+ comma_carriers = [member for member in members if ',' in member]
208
+ if comma_carriers:
209
+ raise ConfigurationError(
210
+ 'roster member contains the batch join delimiter',
211
+ detail=(
212
+ f'{len(comma_carriers)} member value(s) contain a comma '
213
+ f'(first: {comma_carriers[0]!r}); a comma-joined batch '
214
+ f'would carry more ids than members'
215
+ ),
216
+ )
217
+ ordered = sorted(members)
218
+ return tuple(
219
+ ','.join(ordered[start : start + batch_size])
220
+ for start in range(0, len(ordered), batch_size)
221
+ )
@@ -0,0 +1,166 @@
1
+ # src/fleetpull/orchestrator/spine.py
2
+ """The run executor's shared spine: its narrow protocols and drive bundle.
3
+
4
+ The narrow Protocols the run executor and its drive arms depend on instead of
5
+ the concrete state and network classes -- ``ClientSource`` (the registry's
6
+ ``client_for``; the roster refresh coordinator shares this one declaration),
7
+ ``RunRecorder`` (the ledger's lifecycle methods), and ``CursorAccess`` (the
8
+ cursor store's read and its two kind-guarded writes) -- with the three
9
+ state-database surfaces bundled as ``RunStateAccess`` and the whole drive kit
10
+ bundled as ``RunnerSpine``: the collaborators plus the runner-owned seams
11
+ (the one writer-factory call site, the metadata projection) every arm runs
12
+ through. They live here, below the runner and the drive modules alike, so the
13
+ runner can construct the drives without an import cycle.
14
+ """
15
+
16
+ from dataclasses import dataclass
17
+ from datetime import datetime
18
+ from typing import Protocol
19
+
20
+ from fleetpull.config import SyncConfig
21
+ from fleetpull.endpoints.shared import EndpointDefinition
22
+ from fleetpull.incremental import DateWindow, IncrementalCursor
23
+ from fleetpull.model_contract import ResponseModel
24
+ from fleetpull.network.client import TransportClient
25
+ from fleetpull.orchestrator.metadata_projection import MetadataProjection
26
+ from fleetpull.orchestrator.unit_loop import UnitQueue
27
+ from fleetpull.storage import DatasetWriter
28
+ from fleetpull.timing import Clock
29
+ from fleetpull.vocabulary import Provider
30
+
31
+ __all__: list[str] = [
32
+ 'ClientSource',
33
+ 'CursorAccess',
34
+ 'RunRecorder',
35
+ 'RunStateAccess',
36
+ 'RunnerSpine',
37
+ 'WriterFactory',
38
+ ]
39
+
40
+
41
+ class ClientSource(Protocol):
42
+ """The client-lookup surface the executor needs (a subset of the registry)."""
43
+
44
+ def client_for(self, provider: Provider) -> TransportClient:
45
+ """Return the open transport client for a provider."""
46
+ ...
47
+
48
+
49
+ class RunRecorder(Protocol):
50
+ """The run-recording surface the executor needs (a subset of RunLedger)."""
51
+
52
+ def start_snapshot_run(self, provider: Provider, endpoint: str) -> int:
53
+ """Open a snapshot run and return its id."""
54
+ ...
55
+
56
+ def complete_run(
57
+ self, run_id: int, *, row_count: int, to_version: str | None = None
58
+ ) -> None:
59
+ """Close a run as succeeded with its row count (and a feed run's token)."""
60
+ ...
61
+
62
+ def fail_run(self, run_id: int, *, error_detail: str) -> None:
63
+ """Close a run as failed with an error detail."""
64
+ ...
65
+
66
+ def start_window_run(
67
+ self, provider: Provider, endpoint: str, *, window: DateWindow
68
+ ) -> int:
69
+ """Open a watermark run for a window and return its id."""
70
+ ...
71
+
72
+ def start_feed_run(
73
+ self, provider: Provider, endpoint: str, *, from_version: str
74
+ ) -> int:
75
+ """Open a feed run resuming from a token (or seed label) and return its id."""
76
+ ...
77
+
78
+ def coverage_frontier(self, provider: Provider, endpoint: str) -> datetime | None:
79
+ """Return the furthest window end a succeeded run has covered, if any."""
80
+ ...
81
+
82
+
83
+ class CursorAccess(Protocol):
84
+ """The cursor surface the incremental arms need (a subset of CursorStore)."""
85
+
86
+ def get_cursor(self, provider: Provider, endpoint: str) -> IncrementalCursor | None:
87
+ """Return the persisted cursor for a (provider, endpoint), or None."""
88
+ ...
89
+
90
+ def advance_watermark_forward(
91
+ self, provider: Provider, endpoint: str, observed: datetime
92
+ ) -> bool:
93
+ """Atomically advance the date watermark iff strictly forward."""
94
+ ...
95
+
96
+ def commit_feed_token(
97
+ self, provider: Provider, endpoint: str, to_version: str
98
+ ) -> None:
99
+ """Commit the feed cursor, kind-guarded last-write-wins."""
100
+ ...
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class RunStateAccess:
105
+ """The three state-database surfaces one endpoint run commits through.
106
+
107
+ They always travel together -- the composition root builds all three
108
+ over the one state database and the runner's per-unit crash order
109
+ sequences them (parquet -> ledger completion -> unit done-mark ->
110
+ watermark prefix commit) -- so they ride as one collaborator (the
111
+ bundle rule).
112
+
113
+ Attributes:
114
+ recorder: The run ledger's lifecycle surface.
115
+ cursors: The cursor store's read and kind-guarded-write surface.
116
+ units: The work-unit claim queue.
117
+ """
118
+
119
+ recorder: RunRecorder
120
+ cursors: CursorAccess
121
+ units: UnitQueue
122
+
123
+
124
+ class WriterFactory(Protocol):
125
+ """The runner's ONE writer-construction seam, as the drives receive it.
126
+
127
+ A bound view of the runner's ``select_writer`` call site (dataset root
128
+ and dedup switch already closed over), so every arm constructs its
129
+ writer through one place and the routing face is called exactly once
130
+ in the orchestration layer.
131
+ """
132
+
133
+ def __call__(
134
+ self,
135
+ definition: EndpointDefinition[ResponseModel],
136
+ window: DateWindow | None = None,
137
+ ) -> DatasetWriter:
138
+ """Construct the endpoint's writer for this run (and window, if any)."""
139
+ ...
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class RunnerSpine:
144
+ """The whole drive kit: collaborators plus the runner-owned seams.
145
+
146
+ What the snapshot arm and both drive classes (the watermark and feed
147
+ drives) run through, bundled once (the bundle rule) so the drives
148
+ construct from a single parameter in ``EndpointRunner.__init__``.
149
+
150
+ Attributes:
151
+ clients: Hands out an open per-provider client (the registry).
152
+ state: The state-database surfaces -- ledger, cursor store, unit
153
+ queue.
154
+ clock: Supplies the run instant (trailing edge, future-event guard).
155
+ sync: The sync section -- the cold-start anchor, the unit width,
156
+ and the unit worker count.
157
+ make_writer: The runner's one writer-factory call site, bound.
158
+ projection: The post-commit ``metadata.json`` projection.
159
+ """
160
+
161
+ clients: ClientSource
162
+ state: RunStateAccess
163
+ clock: Clock
164
+ sync: SyncConfig
165
+ make_writer: WriterFactory
166
+ projection: MetadataProjection