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,127 @@
1
+ # src/fleetpull/orchestrator/backfill.py
2
+ """Window decomposition: a windowed run's range into per-chunk work units.
3
+
4
+ The caller-side planning the work-unit store defers to its driver (DESIGN §5).
5
+ Every windowed run -- the daily pull and a long backfill alike -- tiles its
6
+ (provider, endpoint) window into whole-UTC-day chunks, one unit per chunk (a
7
+ window smaller than one chunk is a single unit). A unit fans the whole roster
8
+ at execution, so it carries no member -- one planner serves fan-out and
9
+ non-fan-out watermark endpoints alike, the fan-out distinction being the
10
+ driver's, not the unit's. Pure functions only: the runner resolves the window,
11
+ calls these to plan, and drives the queue.
12
+ """
13
+
14
+ from datetime import datetime, timedelta
15
+
16
+ from fleetpull.incremental import DateWindow
17
+ from fleetpull.state import WorkUnitSpec
18
+ from fleetpull.vocabulary import Provider
19
+
20
+ __all__: list[str] = ['plan_backfill_units']
21
+
22
+
23
+ def _is_utc_midnight(value: datetime) -> bool:
24
+ """True when ``value`` is exactly midnight UTC -- a whole-UTC-day boundary.
25
+
26
+ A date-partition boundary: a zero UTC offset and a zeroed time of day. Naive
27
+ and non-UTC datetimes are rejected (``utcoffset`` is ``None`` or nonzero), so
28
+ this also gates the timezone validity ``DateWindow`` does not.
29
+ """
30
+ return (
31
+ value.utcoffset() == timedelta(0)
32
+ and value.hour == 0
33
+ and value.minute == 0
34
+ and value.second == 0
35
+ and value.microsecond == 0
36
+ )
37
+
38
+
39
+ def _date_chunks(span: DateWindow, chunk: timedelta) -> list[tuple[datetime, datetime]]:
40
+ """Tile a half-open span into contiguous whole-UTC-day chunks.
41
+
42
+ Splits ``[span.start, span.end)`` into half-open chunks of ``chunk`` width,
43
+ left to right; the final chunk runs to ``span.end`` and so may be shorter
44
+ (but still a whole number of days). A chunk is emitted only while the cursor
45
+ is strictly before ``span.end``, so no zero-width chunk is produced and every
46
+ bound pair satisfies the work-unit store's ``chunk_start < chunk_end``.
47
+
48
+ Both span bounds must be midnight UTC and ``chunk`` a positive whole number of
49
+ days, so every chunk bound lands on midnight UTC. The date-partitioned
50
+ watermark writer replaces whole date partitions, so a chunk boundary mid-day
51
+ would drive partial-day replacement and silently corrupt the partitions it
52
+ touches -- hence the guards rather than a permissive ``timedelta``.
53
+
54
+ Args:
55
+ span: The half-open range to tile; both bounds midnight UTC.
56
+ chunk: The width of each chunk; a positive whole number of days.
57
+
58
+ Returns:
59
+ The chunk bounds in order, each ``(chunk_start, chunk_end)`` on midnight
60
+ UTC with ``chunk_start < chunk_end``; contiguous (each chunk's end is the
61
+ next's start), the first at ``span.start`` and the last at ``span.end``.
62
+
63
+ Raises:
64
+ ValueError: When ``chunk`` is not a positive whole number of days, or a
65
+ span bound is not midnight UTC -- caller bugs, kept stdlib.
66
+ """
67
+ if chunk <= timedelta(0) or chunk % timedelta(days=1) != timedelta(0):
68
+ raise ValueError(
69
+ f'backfill chunk must be a positive whole number of days: {chunk!r}'
70
+ )
71
+ if not _is_utc_midnight(span.start) or not _is_utc_midnight(span.end):
72
+ raise ValueError(
73
+ 'backfill chunks require whole-UTC-day span bounds (midnight UTC): '
74
+ f'start={span.start!r}, end={span.end!r}'
75
+ )
76
+ chunks: list[tuple[datetime, datetime]] = []
77
+ start = span.start
78
+ while start < span.end:
79
+ end = min(start + chunk, span.end)
80
+ chunks.append((start, end))
81
+ start = end
82
+ return chunks
83
+
84
+
85
+ def plan_backfill_units(
86
+ provider: Provider,
87
+ endpoint: str,
88
+ span: DateWindow,
89
+ chunk: timedelta,
90
+ ) -> list[WorkUnitSpec]:
91
+ """Decompose a backfill into one work unit per whole-UTC-day chunk.
92
+
93
+ The caller-side decomposition the work-unit store leaves to its driver
94
+ (DESIGN §5): tile the span into whole-UTC-day chunks, one unit per chunk. A
95
+ unit fans the whole roster at execution, so it carries no member
96
+ (``partition_key=None``); the unit loop drives each chunk through the
97
+ endpoint's already-resolved driver. So one planner serves fan-out and
98
+ non-fan-out watermark endpoints alike; the fan-out distinction is the
99
+ driver's, not the unit's. Pure: it returns the specs; enqueuing them
100
+ idempotently is the caller's, kept separate so the plan can be inspected and
101
+ the enqueue stays the store's one write.
102
+
103
+ Args:
104
+ provider: The provider being backfilled.
105
+ endpoint: The endpoint being backfilled.
106
+ span: The full backfill range, half-open and midnight-UTC on both bounds
107
+ (the runner's residual-window resolution builds it from the resume
108
+ precedence against the trailing edge).
109
+ chunk: The width of each date chunk; a positive whole number of days.
110
+
111
+ Returns:
112
+ One ``WorkUnitSpec`` per chunk, in chronological order, ready to enqueue.
113
+
114
+ Raises:
115
+ ValueError: When the span bounds are not midnight UTC or ``chunk`` is not a
116
+ positive whole number of days (from :func:`_date_chunks`).
117
+ """
118
+ return [
119
+ WorkUnitSpec(
120
+ provider=provider,
121
+ endpoint=endpoint,
122
+ partition_key=None,
123
+ chunk_start=chunk_start,
124
+ chunk_end=chunk_end,
125
+ )
126
+ for chunk_start, chunk_end in _date_chunks(span, chunk)
127
+ ]
@@ -0,0 +1,155 @@
1
+ # src/fleetpull/orchestrator/batch.py
2
+ """Per-batch transform: validate and frame one record batch, and (watermark only)
3
+ guard, window-filter, and fold its event time.
4
+
5
+ ``process_batch`` is the shared per-batch step both run-executor arms drive: the
6
+ snapshot arm passes ``context=None`` (validate + frame only); the watermark arm
7
+ passes a ``WindowContext`` to additionally apply the future-event guard, filter the
8
+ frame to the resume window, and produce the batch's in-window fold candidate.
9
+ ``combine_latest_event_time`` folds those candidates across batches. Pure
10
+ transforms -- the caller writes the frame and commits the watermark (DESIGN §14).
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+
16
+ import polars as pl
17
+
18
+ from fleetpull.endpoints.shared import EndpointDefinition
19
+ from fleetpull.exceptions import ProviderResponseError
20
+ from fleetpull.incremental import DateWindow
21
+ from fleetpull.model_contract import ResponseModel
22
+ from fleetpull.records import latest_event_time, models_to_dataframe, validate_records
23
+ from fleetpull.storage import in_window
24
+ from fleetpull.vocabulary import JsonObject
25
+
26
+ __all__: list[str] = [
27
+ 'ProcessedBatch',
28
+ 'WindowContext',
29
+ 'combine_latest_event_time',
30
+ 'process_batch',
31
+ ]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class WindowContext:
36
+ """The watermark arm's per-batch context: window, clock, and event column.
37
+
38
+ Present only on the watermark path; ``process_batch`` takes
39
+ ``WindowContext | None`` and treats ``None`` as the snapshot path (no
40
+ guard, no window filter, no fold candidate).
41
+
42
+ Attributes:
43
+ window: The resolved half-open ``[start, end)`` resume window.
44
+ now: The run's clock instant, for the future-event guard.
45
+ event_time_column: The response model's UTC datetime field the window
46
+ filter and fold read.
47
+ """
48
+
49
+ window: DateWindow
50
+ now: datetime
51
+ event_time_column: str
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class ProcessedBatch:
56
+ """One processed batch: the frame to write and its fold candidate.
57
+
58
+ Attributes:
59
+ frame: The frame to hand to ``writer.write`` -- the validated, framed
60
+ batch on the snapshot path; that frame filtered to the resume
61
+ window on the watermark path. Its ``height`` is the run's per-batch
62
+ row count (one row per validated model; the ledger counts the rows
63
+ written for the window).
64
+ latest_event_time: The maximum in-window event time in this batch, or
65
+ ``None`` (snapshot path, or an empty/all-filtered batch). The
66
+ cross-batch fold input.
67
+ """
68
+
69
+ frame: pl.DataFrame
70
+ latest_event_time: datetime | None
71
+
72
+
73
+ def process_batch(
74
+ batch: list[JsonObject],
75
+ definition: EndpointDefinition[ResponseModel],
76
+ context: WindowContext | None,
77
+ ) -> ProcessedBatch:
78
+ """Validate, frame, and (watermark only) guard-and-window one batch.
79
+
80
+ The shared per-batch transform both runner arms drive. Snapshot path
81
+ (``context is None``): validate the raw records against the response model
82
+ and frame them; the frame is written as-is and carries no fold candidate.
83
+ Watermark path: additionally apply the future-event guard to the raw frame,
84
+ filter the frame to the resume window, and fold the in-window maximum event
85
+ time.
86
+
87
+ The guard runs on the *raw* frame, before the window filter: the window's
88
+ end is at or before ``now`` (the trailing edge is held back), so a
89
+ future-dated record falls outside the window and the filter would silently
90
+ drop it -- guarding the raw frame surfaces the anomaly instead. The fold
91
+ uses the *filtered* frame: an event time past ``window.end`` would otherwise
92
+ advance the watermark past the trailing edge and skip the next run's cutoff
93
+ holdback.
94
+
95
+ Args:
96
+ batch: One batch of raw response records from the driver.
97
+ definition: The endpoint binding (response model for validation).
98
+ context: The watermark per-batch context, or ``None`` for snapshot.
99
+
100
+ Returns:
101
+ The frame to write and its fold candidate.
102
+
103
+ Raises:
104
+ ProviderResponseError: A raw event time exceeds ``context.now`` -- a
105
+ contract violation (watermark path only). Validation and framing
106
+ errors propagate from ``validate_records`` / ``models_to_dataframe``
107
+ unchanged.
108
+
109
+ Side Effects:
110
+ None -- pure transform; the caller writes the frame.
111
+ """
112
+ models = validate_records(batch, definition.response_model)
113
+ frame = models_to_dataframe(models, definition.response_model)
114
+ if context is None:
115
+ return ProcessedBatch(frame=frame, latest_event_time=None)
116
+ observed_raw = latest_event_time(frame, context.event_time_column)
117
+ if observed_raw is not None and observed_raw > context.now:
118
+ raise ProviderResponseError(
119
+ provider=definition.provider.value,
120
+ endpoint=definition.name,
121
+ detail=(
122
+ f'observed event time {observed_raw.isoformat()} is after the '
123
+ f'run clock {context.now.isoformat()}'
124
+ ),
125
+ )
126
+ in_scope = frame.filter(in_window(context.event_time_column, context.window))
127
+ return ProcessedBatch(
128
+ frame=in_scope,
129
+ latest_event_time=latest_event_time(in_scope, context.event_time_column),
130
+ )
131
+
132
+
133
+ def combine_latest_event_time(
134
+ running: datetime | None, candidate: datetime | None
135
+ ) -> datetime | None:
136
+ """Fold a batch's in-window max into the running watermark candidate.
137
+
138
+ None-tolerant: an empty or all-filtered batch contributes ``None`` and
139
+ leaves the running maximum unchanged.
140
+
141
+ Args:
142
+ running: The accumulated maximum so far, or ``None``.
143
+ candidate: This batch's in-window maximum, or ``None``.
144
+
145
+ Returns:
146
+ The greater of the two, or whichever is non-``None``, or ``None``.
147
+
148
+ Side Effects:
149
+ None.
150
+ """
151
+ if running is None:
152
+ return candidate
153
+ if candidate is None:
154
+ return running
155
+ return max(running, candidate)
@@ -0,0 +1,271 @@
1
+ # src/fleetpull/orchestrator/bisection.py
2
+ """The bisecting request driver: complete fetches from capped, unsortable Gets.
3
+
4
+ The third ``RequestDriver`` (the request-cardinality seam,
5
+ ``orchestrator/drivers.py``): for endpoints declaring the
6
+ ``BisectedWindowFetch`` request shape, the unit's resume window is
7
+ fetched whole; a
8
+ response of exactly the declared ``results_limit`` is the overflow
9
+ signal — the page is discarded, the window halves at a whole-second
10
+ midpoint, and both halves recurse left-to-right; a floor-width window
11
+ still returning a full page raises loudly (the data is denser than
12
+ windowed fetching can enumerate — the provider's feed transport is the
13
+ escape for such streams). Overflow is a return-type condition (page
14
+ length), never an exception.
15
+
16
+ Fetch grain thereby decouples from write grain: work units and the
17
+ delete-by-window merge stay whole-window while only the wire requests
18
+ narrow. Every sub-request rides ``client.fetch_pages`` — one limiter
19
+ token per attempt, exactly like any page walk — and a mid-recursion
20
+ crash re-claims the whole unit, idempotent under delete-by-window
21
+ (no bisection state is ever persisted).
22
+
23
+ Under overlap-matched retrieval a record straddling an internal split
24
+ boundary is returned by both neighboring leaves, so each emitted page
25
+ is filtered to the records ANCHORED in its own leaf window (the
26
+ binding's ``event_time_wire_key``): leaves partition the unit, every
27
+ record has exactly one owning leaf, and write-time dedup stays hygiene
28
+ rather than a correctness mechanism. Midpoints are computed at whole
29
+ seconds because fractional-second search bounds are unprobed on the
30
+ wire.
31
+ """
32
+
33
+ import logging
34
+ from collections.abc import Iterator
35
+ from dataclasses import dataclass
36
+ from datetime import datetime, timedelta
37
+
38
+ from fleetpull.endpoints.shared import (
39
+ BisectedWindowFetch,
40
+ EndpointDefinition,
41
+ ResumeValue,
42
+ require_date_window,
43
+ )
44
+ from fleetpull.exceptions import ProviderResponseError
45
+ from fleetpull.incremental import DateWindow
46
+ from fleetpull.model_contract import ResponseModel
47
+ from fleetpull.network.client import FetchedPage, TransportClient
48
+ from fleetpull.vocabulary import JsonObject
49
+
50
+ __all__: list[str] = ['BisectingWindowDriver']
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class _BisectionTally:
57
+ """Mutable leaf/overflow counters threaded through one unit's recursion.
58
+
59
+ Narration state only -- the fetch and filter semantics never read it.
60
+ ``record_batches`` folds it into the unit-level INFO line so an
61
+ operator sees that (and how hard) bisection engaged, without a log
62
+ line per leaf.
63
+
64
+ Attributes:
65
+ leaves: Non-overflowing windows fetched and yielded.
66
+ overflows: Windows that returned exactly ``results_limit`` records
67
+ and were halved.
68
+ """
69
+
70
+ leaves: int = 0
71
+ overflows: int = 0
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class BisectingWindowDriver:
76
+ """Fetch a windowed endpoint completely by adaptive window bisection.
77
+
78
+ Attributes:
79
+ shape: The endpoint's declared ``BisectedWindowFetch`` facts — the
80
+ overflow threshold, the floor width, and the wire key that
81
+ anchors each record to its one owning leaf window.
82
+ """
83
+
84
+ shape: BisectedWindowFetch
85
+
86
+ def record_batches(
87
+ self,
88
+ definition: EndpointDefinition[ResponseModel],
89
+ client: TransportClient,
90
+ resume: ResumeValue,
91
+ ) -> Iterator[FetchedPage]:
92
+ """Yield one ownership-filtered batch per non-overflowing leaf window.
93
+
94
+ Args:
95
+ definition: The endpoint being run (its ``spec_builder``,
96
+ ``page_decoder``, and ``quota_scope``).
97
+ client: The transport client for this endpoint's provider.
98
+ resume: The run's resume window. Must be a ``DateWindow`` — a
99
+ watermark endpoint always resumes from one; any other
100
+ value is a wiring bug.
101
+
102
+ Yields:
103
+ One ``FetchedPage`` per leaf window, left-to-right, each
104
+ holding only the records anchored in that leaf.
105
+
106
+ Raises:
107
+ TypeError: ``resume`` is not a ``DateWindow``.
108
+ ProviderResponseError: A floor-width window still returned a
109
+ full page (the loud no-narrower failure), or a record
110
+ arrived without a parseable anchor timestamp.
111
+ """
112
+ window = require_date_window(resume, type(self).__name__)
113
+ tally = _BisectionTally()
114
+ yield from self._drive_window(definition, client, window, tally)
115
+ if tally.overflows:
116
+ logger.info(
117
+ 'bisection complete: provider=%s endpoint=%s window_start=%s '
118
+ 'window_end=%s leaves=%d overflows=%d',
119
+ definition.provider.value,
120
+ definition.name,
121
+ window.start.isoformat(),
122
+ window.end.isoformat(),
123
+ tally.leaves,
124
+ tally.overflows,
125
+ )
126
+
127
+ def _drive_window(
128
+ self,
129
+ definition: EndpointDefinition[ResponseModel],
130
+ client: TransportClient,
131
+ window: DateWindow,
132
+ tally: _BisectionTally,
133
+ ) -> Iterator[FetchedPage]:
134
+ """Fetch one window; recurse on overflow, yield the leaf otherwise.
135
+
136
+ Args:
137
+ definition: The endpoint being run.
138
+ client: The transport client for this endpoint's provider.
139
+ window: The window to fetch — the unit's resume window at the
140
+ top of the recursion, a half of a parent below it.
141
+ tally: The recursion's shared leaf/overflow counters, folded
142
+ into the unit-level narration by ``record_batches``.
143
+
144
+ Yields:
145
+ The leaf batches under this window, left-to-right.
146
+
147
+ Raises:
148
+ ProviderResponseError: Per ``record_batches``.
149
+ """
150
+ spec = definition.spec_builder.build_spec(resume=window, member_values={})
151
+ pages = list(
152
+ client.fetch_pages(
153
+ spec, definition.page_decoder, definition.quota_scope.value
154
+ )
155
+ )
156
+ # The endpoint's decoder is single-page (terminal on the first
157
+ # page), so the chain is exactly one page.
158
+ records = [record for page in pages for record in page.records]
159
+ if len(records) < self.shape.results_limit:
160
+ tally.leaves += 1
161
+ yield FetchedPage(
162
+ records=self._anchored_in(records, window, definition),
163
+ durable_progress=None,
164
+ )
165
+ return
166
+ width = window.end - window.start
167
+ if width <= self.shape.floor:
168
+ raise ProviderResponseError(
169
+ provider=definition.provider.value,
170
+ endpoint=definition.name,
171
+ detail=(
172
+ f'a {width} window starting {window.start.isoformat()} '
173
+ f'still returned {len(records)} records — the window '
174
+ f'cannot be narrowed under the provider record cap. '
175
+ f'The stream is denser than windowed fetching can '
176
+ f'enumerate; the provider feed transport is the '
177
+ f'escape for this endpoint at this density.'
178
+ ),
179
+ )
180
+ tally.overflows += 1
181
+ logger.debug(
182
+ 'window overflowed: provider=%s endpoint=%s window_start=%s '
183
+ 'window_end=%s records=%d; halving',
184
+ definition.provider.value,
185
+ definition.name,
186
+ window.start.isoformat(),
187
+ window.end.isoformat(),
188
+ len(records),
189
+ )
190
+ midpoint = _whole_second_midpoint(window.start, window.end)
191
+ yield from self._drive_window(
192
+ definition, client, DateWindow(start=window.start, end=midpoint), tally
193
+ )
194
+ yield from self._drive_window(
195
+ definition, client, DateWindow(start=midpoint, end=window.end), tally
196
+ )
197
+
198
+ def _anchored_in(
199
+ self,
200
+ records: list[JsonObject],
201
+ window: DateWindow,
202
+ definition: EndpointDefinition[ResponseModel],
203
+ ) -> list[JsonObject]:
204
+ """Keep the records whose anchor timestamp falls in the window.
205
+
206
+ Under overlap-matched retrieval a straddler of an internal split
207
+ boundary is fetched by both neighboring leaves; anchoring gives it
208
+ exactly one owner. Records anchored outside the unit's whole
209
+ window (overlap edge returns) drop here too — the same records
210
+ the runner's window filter would drop after modeling.
211
+
212
+ Args:
213
+ records: The leaf page's raw records.
214
+ window: The leaf window that owns anchored records.
215
+ definition: The endpoint being run, for the error detail.
216
+
217
+ Returns:
218
+ The records anchored in ``[window.start, window.end)``.
219
+
220
+ Raises:
221
+ ProviderResponseError: A record's anchor key is missing or
222
+ unparseable — the routing anchor is load-bearing, so a
223
+ record without one fails loudly rather than being
224
+ silently kept or dropped.
225
+ """
226
+ wire_key = self.shape.event_time_wire_key
227
+ anchored: list[JsonObject] = []
228
+ for record in records:
229
+ raw_value = record.get(wire_key)
230
+ if not isinstance(raw_value, str):
231
+ raise ProviderResponseError(
232
+ provider=definition.provider.value,
233
+ endpoint=definition.name,
234
+ detail=(
235
+ f'record is missing the anchor timestamp '
236
+ f'{wire_key!r} bisection routes by.'
237
+ ),
238
+ )
239
+ try:
240
+ anchor = datetime.fromisoformat(raw_value)
241
+ except ValueError as error:
242
+ raise ProviderResponseError(
243
+ provider=definition.provider.value,
244
+ endpoint=definition.name,
245
+ detail=(
246
+ f'unparseable anchor timestamp {raw_value!r} under '
247
+ f'{wire_key!r}.'
248
+ ),
249
+ ) from error
250
+ if window.start <= anchor < window.end:
251
+ anchored.append(record)
252
+ return anchored
253
+
254
+
255
+ def _whole_second_midpoint(start: datetime, end: datetime) -> datetime:
256
+ """The window's midpoint, floored to a whole second.
257
+
258
+ Fractional-second search bounds are unprobed on the wire, so splits
259
+ stay second-granular; the halves differ by at most one second, which
260
+ the recursion absorbs.
261
+
262
+ Args:
263
+ start: The window's inclusive start.
264
+ end: The window's exclusive end.
265
+
266
+ Returns:
267
+ The floored midpoint, strictly between ``start`` and ``end`` for
268
+ any window wider than one second.
269
+ """
270
+ half_seconds = int((end - start).total_seconds() // 2)
271
+ return start + timedelta(seconds=half_seconds)