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