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,266 @@
1
+ # src/fleetpull/orchestrator/feed_drive.py
2
+ """The feed drive: the run executor's per-page version-token arm.
3
+
4
+ ``FeedDrive`` drives one feed endpoint's version-token stream (DESIGN
5
+ sections 4/14, built 2026-07-21): resume is the stored ``FeedToken``, or a
6
+ ``FeedSeed`` at the sync-wide cold-start anchor when none is stored (the
7
+ seed rides ONLY the tokenless first run -- I4); each page then commits
8
+ independently in the per-page crash order parquet BEFORE token (I1/I2) --
9
+ the append writer lands the page's rows durably, then the page's
10
+ ``toVersion`` commits through the store's kind-guarded feed write -- so a
11
+ crash between the two refetches exactly one page on the next run and its
12
+ rows land again as new appended rows, harmless under the stored-as-emitted
13
+ contract. The drive consumes the page stream directly
14
+ (``stream_processed_batches`` deliberately drops ``durable_progress`` and
15
+ stays the non-feed pipe).
16
+ """
17
+
18
+ import logging
19
+ from collections.abc import Iterator
20
+
21
+ from fleetpull.endpoints.shared import EndpointDefinition
22
+ from fleetpull.exceptions import ConfigurationError
23
+ from fleetpull.incremental import FeedResume, FeedSeed, FeedToken
24
+ from fleetpull.model_contract import ResponseModel
25
+ from fleetpull.network.client import FetchedPage
26
+ from fleetpull.orchestrator.batch import process_batch
27
+ from fleetpull.orchestrator.drivers import RequestDriver
28
+ from fleetpull.orchestrator.outcome import Executed, RunOutcome
29
+ from fleetpull.orchestrator.recording import recorded_run
30
+ from fleetpull.orchestrator.resume import resolve_feed_resume
31
+ from fleetpull.orchestrator.spine import RunnerSpine
32
+ from fleetpull.orchestrator.streaming import BatchObserver
33
+ from fleetpull.storage import DatasetWriter
34
+ from fleetpull.timing import to_iso8601
35
+ from fleetpull.vocabulary import Provider
36
+
37
+ __all__: list[str] = ['FeedDrive']
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ def _feed_resume_label(resume: FeedResume) -> str:
43
+ """The ledger's ``from_version`` text for a feed run's resume value.
44
+
45
+ A resumed run records the token verbatim; a seeded run records the
46
+ self-describing ``seed:<iso8601>`` marker (the ``runs`` table requires a
47
+ non-null ``from_version`` on a feed row, and the seed date IS what the
48
+ run resumed from -- the convention is recorded on
49
+ ``RunLedger.start_feed_run``).
50
+
51
+ Args:
52
+ resume: The run's resolved feed resume value.
53
+
54
+ Returns:
55
+ The token, or ``seed:<iso8601 start>`` for a seeded run.
56
+ """
57
+ match resume:
58
+ case FeedToken(from_version=from_version):
59
+ return from_version
60
+ case FeedSeed(start=start):
61
+ return f'seed:{to_iso8601(start)}'
62
+
63
+
64
+ def _log_feed_resume(provider: Provider, endpoint: str, resume: FeedResume) -> None:
65
+ """Narrate a feed run's resume point at INFO: seeded or resumed.
66
+
67
+ Args:
68
+ provider: The endpoint's provider.
69
+ endpoint: The endpoint name.
70
+ resume: The run's resolved feed resume value.
71
+
72
+ Side Effects:
73
+ Emits one INFO line.
74
+ """
75
+ match resume:
76
+ case FeedToken(from_version=from_version):
77
+ logger.info(
78
+ 'feed run resumed: provider=%s endpoint=%s from_version=%s',
79
+ provider.value,
80
+ endpoint,
81
+ from_version,
82
+ )
83
+ case FeedSeed(start=start):
84
+ logger.info(
85
+ 'feed run seeded: provider=%s endpoint=%s from_date=%s',
86
+ provider.value,
87
+ endpoint,
88
+ to_iso8601(start),
89
+ )
90
+
91
+
92
+ class FeedDrive:
93
+ """Drives one feed endpoint's version-token stream, page by page.
94
+
95
+ Constructed once by ``EndpointRunner.__init__`` from the shared spine;
96
+ ``run`` takes the endpoint, its request driver, and the optional
97
+ per-frame observer.
98
+ """
99
+
100
+ def __init__(self, spine: RunnerSpine) -> None:
101
+ """
102
+ Args:
103
+ spine: The shared drive kit -- collaborators plus the runner's
104
+ writer-factory and projection seams.
105
+ """
106
+ self._spine = spine
107
+
108
+ def run(
109
+ self,
110
+ definition: EndpointDefinition[ResponseModel],
111
+ driver: RequestDriver,
112
+ observer: BatchObserver | None,
113
+ ) -> RunOutcome:
114
+ """Run the feed arm: drive the version-token stream, page by page.
115
+
116
+ Resume is the stored ``FeedToken`` used directly, or a ``FeedSeed``
117
+ at the sync-wide cold-start anchor when no token is stored -- the
118
+ seed rides ONLY the tokenless first run (I4; ``resolve_feed_resume``
119
+ makes it structural). The client is resolved before the run is
120
+ opened, so an unconfigured provider opens no dangling run. Each page
121
+ then commits independently through ``_consume_feed_pages`` in the
122
+ per-page crash order (parquet before token -- I1/I2); the ledger row
123
+ closes with the run's total row count and final ``toVersion`` after
124
+ the stream drains. A crash mid-stream leaves the run ``running``
125
+ (diagnostic only, the §5 stance) with every completed page's parquet
126
+ and token already committed -- the next run resumes from the last
127
+ committed token and refetches exactly one page, whose rows append
128
+ again as duplicates the stored-as-emitted contract absorbs (§4).
129
+
130
+ Args:
131
+ definition: The feed endpoint to run.
132
+ driver: The request driver supplying the run's pages (a
133
+ ``SingleRequestDriver`` -- feeds are single-chain).
134
+ observer: The optional per-frame hook, handed each
135
+ post-validation frame as the run streams.
136
+
137
+ Returns:
138
+ ``Executed`` with the fetched-row count and the append report.
139
+
140
+ Raises:
141
+ ConfigurationError: A watermark cursor is stored for this feed
142
+ endpoint (cross-mode corruption, from ``resolve_feed_resume``
143
+ -- raised before any run is opened), or a page carried no
144
+ durable progress (a non-feed decoder wired to a feed
145
+ endpoint).
146
+ FleetpullError: A fetch, validation, write, or completion failure
147
+ -- the run is recorded failed and the original error
148
+ re-raised; pages committed before it stand.
149
+ """
150
+ provider = definition.provider
151
+ name = definition.name
152
+ state = self._spine.state
153
+ client = self._spine.clients.client_for(provider)
154
+ resume = resolve_feed_resume(
155
+ state.cursors.get_cursor(provider, name),
156
+ self._spine.sync.default_start_datetime,
157
+ provider,
158
+ name,
159
+ )
160
+ _log_feed_resume(provider, name, resume)
161
+ run_id = state.recorder.start_feed_run(
162
+ provider, name, from_version=_feed_resume_label(resume)
163
+ )
164
+ with recorded_run(state.recorder, run_id):
165
+ writer = self._spine.make_writer(definition)
166
+ pages = driver.record_batches(definition, client, resume)
167
+ records_fetched, page_count, last_token = self._consume_feed_pages(
168
+ definition, pages, writer, observer
169
+ )
170
+ write = writer.finalize()
171
+ state.recorder.complete_run(
172
+ run_id, row_count=records_fetched, to_version=last_token
173
+ )
174
+ logger.info(
175
+ 'feed complete: provider=%s endpoint=%s pages=%d records=%d to_version=%s',
176
+ provider.value,
177
+ name,
178
+ page_count,
179
+ records_fetched,
180
+ last_token,
181
+ )
182
+ outcome = Executed(records_fetched=records_fetched, write=write)
183
+ self._spine.projection.project(definition, outcome, window=None)
184
+ return outcome
185
+
186
+ def _consume_feed_pages(
187
+ self,
188
+ definition: EndpointDefinition[ResponseModel],
189
+ pages: Iterator[FetchedPage],
190
+ writer: DatasetWriter,
191
+ observer: BatchObserver | None,
192
+ ) -> tuple[int, int, str]:
193
+ """Consume the feed stream: per page, parquet BEFORE token (I1/I2).
194
+
195
+ The per-page transaction (DESIGN section 14): validate and frame the
196
+ page (``process_batch`` with no window context -- the feed has no
197
+ window, no future-event guard, no fold; whatever the stream emits is
198
+ stored), hand the frame to the observer where one rides, append it
199
+ durably (the feed writer's ``write`` is durable on return -- the
200
+ append-log cell's contract), and only THEN commit the page's
201
+ ``toVersion``. The token therefore never moves past unwritten data:
202
+ a crash between the two loses only the token, and the next run
203
+ refetches that one page. An empty page (the at-head terminal)
204
+ appends nothing and re-commits its unchanged token -- the feed
205
+ always has a cursor to write (section 5).
206
+
207
+ Args:
208
+ definition: The feed endpoint being run.
209
+ pages: The driver's fetched-page stream, ready to consume.
210
+ writer: The run's append writer, handed each frame in order.
211
+ observer: The optional per-frame hook.
212
+
213
+ Returns:
214
+ The fetched-row count, the page count, and the final committed
215
+ ``toVersion``.
216
+
217
+ Raises:
218
+ ConfigurationError: A page carried no ``durable_progress`` -- a
219
+ non-feed decoder is wired to a feed endpoint, a construction
220
+ bug surfaced before the page's rows are written.
221
+ RuntimeError: The stream yielded no pages, violating the
222
+ client's at-least-one-page contract.
223
+
224
+ Side Effects:
225
+ Appends part files and commits the feed cursor, page by page.
226
+ """
227
+ records_fetched = 0
228
+ page_count = 0
229
+ last_token: str | None = None
230
+ for page in pages:
231
+ token = page.durable_progress
232
+ if token is None:
233
+ raise ConfigurationError(
234
+ 'feed page carries no durable progress',
235
+ provider=definition.provider.value,
236
+ endpoint=definition.name,
237
+ detail=(
238
+ 'the endpoint declares FeedMode but its page decoder '
239
+ 'yielded no resume token -- a non-feed decoder is wired '
240
+ 'to a feed endpoint'
241
+ ),
242
+ )
243
+ processed = process_batch(page.records, definition, None)
244
+ if observer is not None:
245
+ observer(processed.frame)
246
+ writer.write(processed.frame)
247
+ self._spine.state.cursors.commit_feed_token(
248
+ definition.provider, definition.name, token
249
+ )
250
+ records_fetched += processed.frame.height
251
+ page_count += 1
252
+ last_token = token
253
+ logger.debug(
254
+ 'feed page appended: provider=%s endpoint=%s page=%d records=%d '
255
+ 'to_version=%s',
256
+ definition.provider.value,
257
+ definition.name,
258
+ page_count,
259
+ processed.frame.height,
260
+ token,
261
+ )
262
+ if last_token is None:
263
+ raise RuntimeError(
264
+ 'feed drive yielded no pages -- fetch_pages always drives at least one'
265
+ )
266
+ return records_fetched, page_count, last_token
@@ -0,0 +1,195 @@
1
+ # src/fleetpull/orchestrator/metadata_projection.py
2
+ """The ``metadata.json`` projection: a committed run's facts, humanly readable.
3
+
4
+ ``MetadataProjection`` writes the per-endpoint ``metadata.json`` snapshot
5
+ after a successful run fully commits (DESIGN §3) -- post-commit and
6
+ best-effort, never part of the run's transaction: the outcome's counts, the
7
+ run's resolved window, and a cursor read-back from the store flatten into a
8
+ ``MetadataSnapshot`` the storage face renders and atomically writes. The
9
+ program never reads the file back (SQLite stays the single source of truth,
10
+ §5). ``sync_mode_label`` also serves the runner's endpoint-start narration,
11
+ so renaming a label changes both surfaces together.
12
+ """
13
+
14
+ import logging
15
+ from typing import Protocol
16
+
17
+ from fleetpull.endpoints.shared import (
18
+ EndpointDefinition,
19
+ FeedMode,
20
+ SnapshotMode,
21
+ SyncMode,
22
+ WatermarkMode,
23
+ )
24
+ from fleetpull.incremental import (
25
+ DateWatermark,
26
+ DateWindow,
27
+ FeedToken,
28
+ IncrementalCursor,
29
+ )
30
+ from fleetpull.model_contract import ResponseModel
31
+ from fleetpull.orchestrator.outcome import Executed
32
+ from fleetpull.paths import PathInput, endpoint_directory
33
+ from fleetpull.state import CursorKind
34
+ from fleetpull.storage import (
35
+ MetadataSnapshot,
36
+ render_metadata_json,
37
+ write_metadata_json,
38
+ )
39
+ from fleetpull.timing import Clock, to_iso8601
40
+ from fleetpull.vocabulary import Provider
41
+
42
+ __all__: list[str] = ['CursorReader', 'MetadataProjection', 'sync_mode_label']
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ class CursorReader(Protocol):
48
+ """The one-method cursor read-back the projection needs."""
49
+
50
+ def get_cursor(self, provider: Provider, endpoint: str) -> IncrementalCursor | None:
51
+ """Return the persisted cursor for a (provider, endpoint), or None."""
52
+ ...
53
+
54
+
55
+ def sync_mode_label(sync_mode: SyncMode) -> str:
56
+ """The sync mode's human-readable label.
57
+
58
+ Shared by the ``metadata.json`` projection and the endpoint-start
59
+ narration line -- renaming a label changes both surfaces.
60
+
61
+ Args:
62
+ sync_mode: The endpoint's declared sync mode.
63
+
64
+ Returns:
65
+ ``'snapshot'``, ``'watermark'``, or ``'feed'``.
66
+ """
67
+ match sync_mode:
68
+ case SnapshotMode():
69
+ return 'snapshot'
70
+ case WatermarkMode():
71
+ return 'watermark'
72
+ case FeedMode():
73
+ return 'feed'
74
+
75
+
76
+ def _serialize_cursor(
77
+ cursor: IncrementalCursor | None,
78
+ ) -> tuple[str | None, str | None]:
79
+ """Serialize a stored cursor to the metadata projection's plain pair.
80
+
81
+ The storage face never sees the cursor union (the §11 storage/state
82
+ boundary), so the projection flattens it here; the kind labels are the
83
+ cursor store's own ``CursorKind`` discriminators.
84
+
85
+ Args:
86
+ cursor: The stored cursor, or ``None`` when none is persisted.
87
+
88
+ Returns:
89
+ ``(kind, value)`` -- ``('date_watermark', <iso8601>)``,
90
+ ``('feed_token', <token>)``, or ``(None, None)``.
91
+ """
92
+ match cursor:
93
+ case DateWatermark(watermark=watermark):
94
+ return (CursorKind.DATE_WATERMARK.value, to_iso8601(watermark))
95
+ case FeedToken(from_version=from_version):
96
+ return (CursorKind.FEED_TOKEN.value, from_version)
97
+ case None:
98
+ return (None, None)
99
+
100
+
101
+ class MetadataProjection:
102
+ """Projects one committed run's facts into its ``metadata.json``.
103
+
104
+ Constructed once beside the runner with the cursor read-back surface,
105
+ the clock, and the dataset root; ``project`` runs after each arm's
106
+ successful commit.
107
+ """
108
+
109
+ def __init__(
110
+ self, cursors: CursorReader, clock: Clock, dataset_root: PathInput
111
+ ) -> None:
112
+ """
113
+ Args:
114
+ cursors: The cursor store's read surface, for the post-commit
115
+ cursor read-back.
116
+ clock: Supplies the ``generated_at`` instant.
117
+ dataset_root: Where the endpoint output directories live.
118
+ """
119
+ self._cursors = cursors
120
+ self._clock = clock
121
+ self._dataset_root = dataset_root
122
+
123
+ def project(
124
+ self,
125
+ definition: EndpointDefinition[ResponseModel],
126
+ outcome: Executed,
127
+ *,
128
+ window: DateWindow | None,
129
+ ) -> None:
130
+ """Project a committed run's facts into the endpoint's ``metadata.json``.
131
+
132
+ Runs only after a successful run has fully committed (parquet, the
133
+ ledger rows, the unit done-marks, the watermark prefix): the
134
+ outcome's counts, the run's resolved window,
135
+ and a cursor read-back from the store flatten into a
136
+ ``MetadataSnapshot`` the storage face renders and atomically writes
137
+ (DESIGN §3).
138
+
139
+ Args:
140
+ definition: The endpoint that just ran.
141
+ outcome: The run's merged ``Executed`` outcome.
142
+ window: The run's resolved window, or ``None`` when it had none
143
+ (a snapshot or feed run, or a watermark run that only
144
+ re-drove leftover units).
145
+
146
+ Side Effects:
147
+ Writes ``metadata.json`` in the endpoint's output directory; on
148
+ an ``OSError``, logs at ERROR and continues.
149
+ """
150
+ cursor_kind, cursor_value = _serialize_cursor(
151
+ self._cursors.get_cursor(definition.provider, definition.name)
152
+ )
153
+ snapshot = MetadataSnapshot(
154
+ provider=definition.provider.value,
155
+ endpoint=definition.name,
156
+ sync_mode=sync_mode_label(definition.sync_mode),
157
+ generated_at=self._clock.now_utc(),
158
+ records_fetched=outcome.records_fetched,
159
+ rows_written=outcome.write.rows_written,
160
+ duplicates_dropped=outcome.write.duplicates_dropped,
161
+ files_written=outcome.write.files_written,
162
+ deleted_partitions=outcome.write.deleted_partitions,
163
+ window_start=None if window is None else window.start,
164
+ window_end=None if window is None else window.end,
165
+ cursor_kind=cursor_kind,
166
+ cursor_value=cursor_value,
167
+ )
168
+ directory = endpoint_directory(
169
+ self._dataset_root, definition.provider.value, definition.name
170
+ )
171
+ # Only the file write is guarded, and only for OSError: the run is
172
+ # already committed (parquet, ledger, units, watermark), and the file is a
173
+ # cosmetic projection the next successful run rewrites -- failing a
174
+ # committed run over it would be worse than a stale file. A render
175
+ # failure is a bug and propagates. An absent endpoint directory is
176
+ # a healthy no-data state (a seeded-at-head feed, an empty
177
+ # watermark cold run) -- there is nothing to project onto, so the
178
+ # write is skipped quietly rather than alarmed over.
179
+ if not directory.exists():
180
+ logger.debug(
181
+ 'metadata.json skipped: provider=%s endpoint=%s '
182
+ '(no data has ever landed)',
183
+ definition.provider.value,
184
+ definition.name,
185
+ )
186
+ return
187
+ text = render_metadata_json(snapshot)
188
+ try:
189
+ write_metadata_json(directory, text)
190
+ except OSError:
191
+ logger.exception(
192
+ 'metadata.json write failed: provider=%s endpoint=%s',
193
+ definition.provider.value,
194
+ definition.name,
195
+ )
@@ -0,0 +1,53 @@
1
+ # src/fleetpull/orchestrator/outcome.py
2
+ """The run executor's result carrier: what one endpoint run produced.
3
+
4
+ A frozen tagged union the run executor returns instead of ``None``, so the caller
5
+ (the orchestration entry, ``entry.py``) dispatches on the outcome rather than
6
+ inferring it. ``Executed`` carries the fetched-row count and the write report;
7
+ ``CaughtUp`` is the no-op marker for a run whose resume window resolved to
8
+ nothing -- no fetch, no writer, no ledger row. ``CaughtUp`` is reachable only on
9
+ the watermark arm, whose window resolution can find nothing to drive; a snapshot
10
+ always executes.
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+
16
+ from fleetpull.storage import WriteResult
17
+
18
+ __all__: list[str] = ['CaughtUp', 'Executed', 'RunOutcome']
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Executed:
23
+ """A run that fetched and wrote.
24
+
25
+ Attributes:
26
+ records_fetched: The count of records fetched across the run -- the
27
+ ledger's row count, distinct from ``write.rows_written`` (which dedup
28
+ and partitioning can make a different number).
29
+ write: The storage layer's write report for the run.
30
+ latest_observed: The folded in-window maximum event time, or ``None``
31
+ when the run observed no in-window event (an empty unit) or has no
32
+ event-time axis (a snapshot). The prefix-advance watermark rule's
33
+ per-unit datum (DESIGN section 5): the unit loop records it at
34
+ ``mark_done`` and the watermark advances only across the
35
+ contiguous done-prefix -- never here, never per-unit in isolation.
36
+ """
37
+
38
+ records_fetched: int
39
+ write: WriteResult
40
+ latest_observed: datetime | None = None
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class CaughtUp:
45
+ """A run that did nothing because its resume window resolved to empty.
46
+
47
+ No fetch, no writer, no ledger row; carries no fields (the run executor logs the
48
+ detail). Reachable only on the watermark arm, whose window resolution can
49
+ resolve to nothing.
50
+ """
51
+
52
+
53
+ type RunOutcome = Executed | CaughtUp
@@ -0,0 +1,85 @@
1
+ # src/fleetpull/orchestrator/recording.py
2
+ """Record-failure-without-masking: the orchestration layer's shared stance.
3
+
4
+ Every failure-recording write in this layer -- the run executor marking a run
5
+ failed, the roster coordinator marking a harvest run failed, the unit loop
6
+ marking a work unit failed -- touches SQLite, which can itself fail (a locked
7
+ or unwritable database); if it does, that secondary failure must never
8
+ replace the error that actually ended the work. ``record_failure_safely``
9
+ states that stance once: run the recording call, and on any recording failure
10
+ log it and swallow it so the original propagates. ``recorded_run`` is the
11
+ run-scoped composition every run-opening arm wraps its protected block in:
12
+ on any failure inside the block, record the run failed (safely) and re-raise
13
+ the original.
14
+ """
15
+
16
+ import logging
17
+ from collections.abc import Callable, Iterator
18
+ from contextlib import contextmanager
19
+ from functools import partial
20
+ from typing import Protocol
21
+
22
+ __all__: list[str] = ['FailureRecorder', 'record_failure_safely', 'recorded_run']
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class FailureRecorder(Protocol):
28
+ """The one-method fail-a-run surface ``recorded_run`` needs."""
29
+
30
+ def fail_run(self, run_id: int, *, error_detail: str) -> None:
31
+ """Close a run as failed with an error detail."""
32
+ ...
33
+
34
+
35
+ def record_failure_safely(record: Callable[[], None], subject: str) -> None:
36
+ """Run a failure-recording call without masking the original error.
37
+
38
+ Args:
39
+ record: The recording call (``fail_run`` / ``mark_failed``, already
40
+ bound to its arguments).
41
+ subject: What is being marked failed, for the log line (e.g.
42
+ ``'run 5'``, ``'work unit 7'``).
43
+
44
+ Side Effects:
45
+ Runs ``record``; on a recording failure, logs it with its traceback
46
+ and swallows it so the caller's original error propagates.
47
+ """
48
+ try:
49
+ record()
50
+ except Exception:
51
+ logger.exception(
52
+ 'failed to record %s as failed after an earlier error', subject
53
+ )
54
+
55
+
56
+ @contextmanager
57
+ def recorded_run(recorder: FailureRecorder, run_id: int) -> Iterator[None]:
58
+ """Run the block; on any failure, record the run failed and re-raise.
59
+
60
+ The shared spine of every run-opening arm's protected block: a failure
61
+ inside marks the run failed through ``record_failure_safely`` (so a
62
+ recording failure never masks the original) and the original error
63
+ propagates unchanged.
64
+
65
+ Args:
66
+ recorder: The ledger surface whose ``fail_run`` closes the run.
67
+ run_id: The open run to mark failed on a block failure.
68
+
69
+ Yields:
70
+ Nothing -- the protected block runs in the ``with`` body.
71
+
72
+ Raises:
73
+ Exception: Whatever the block raised, unchanged.
74
+
75
+ Side Effects:
76
+ On a block failure, records the run failed (best-effort).
77
+ """
78
+ try:
79
+ yield
80
+ except Exception as error:
81
+ record_failure_safely(
82
+ partial(recorder.fail_run, run_id, error_detail=str(error)),
83
+ f'run {run_id}',
84
+ )
85
+ raise