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,648 @@
1
+ # src/fleetpull/state/work_units.py
2
+ """The work-units store: the backfill claim queue.
3
+
4
+ A backfill decomposes a (provider, endpoint) range into date chunks — and an
5
+ opaque ``partition_key`` (a vehicle id, a driver id, ...) for partitioned
6
+ endpoints — and this store is the claim queue over those units. It is dumb
7
+ persistence plus an atomic claim: it stores the units a caller plans, hands them
8
+ to fetch workers one at a time, records each outcome, and recovers from a crash —
9
+ knowing nothing about HTTP, parquet, chunking, or what a ``partition_key`` means
10
+ (DESIGN §5). Runs after ``migrate_to_head`` — the ``work_units`` table must exist.
11
+
12
+ Enqueue is idempotent (``INSERT OR IGNORE`` on the natural key, with partial unique
13
+ indexes so a NULL ``partition_key`` still dedups), so re-running a backfill plan
14
+ never duplicates units. Idempotency's flip side: a kept ``done`` row silently
15
+ absorbs a re-planned unit with the same bounds, so a planner deliberately
16
+ RE-covering committed ground (the lookback margin — day-aligned units re-tile
17
+ onto identical keys every run) must first call ``release_done_units`` over the
18
+ window it is about to re-plan; the release-then-enqueue pairing at the single
19
+ planning site is what keeps re-covered days fetchable (DESIGN §5, corrected
20
+ 2026-07-21). ``claim_next`` is a single atomic ``UPDATE ... WHERE
21
+ unit_id = (SELECT ... LIMIT 1) RETURNING ...`` — safe under concurrency because WAL
22
+ serializes writers, no app-level lock — that takes the lowest claimable ``unit_id``
23
+ (FIFO), increments ``attempt_count``, and clears the prior attempt's outcome.
24
+ Lifecycle: ``pending → claimed → done | failed``; ``failed`` units are re-served on
25
+ a later pass, unconditionally — there is no attempt cap (``attempt_count`` still
26
+ increments at claim, recorded and narrated, so a crash mid-execution counts). A
27
+ finite cap would convert a persistent failure into a silently skipped unit — a
28
+ coverage hole behind an advancing watermark — so a poison unit fails the endpoint
29
+ loudly every invocation instead, and the prefix-advance rule's gap-unreachability
30
+ argument leans on exactly this always-claimable property (DESIGN §5); a future
31
+ bounded retry policy must re-derive that argument before adding one.
32
+
33
+ Crash recovery is a startup reset: a single ``fleetpull`` invocation runs the whole
34
+ backfill as one process, so at startup any ``claimed`` row is stale (its worker is
35
+ gone) and ``reset_claimed_to_pending`` reverts it — no lease, no heartbeat (sound
36
+ only because two invocations never run against one state DB at once). An
37
+ unparseable stored chunk is state-store corruption and raises ``ConfigurationError``,
38
+ the same stance as the cursor store.
39
+ """
40
+
41
+ import logging
42
+ from collections.abc import Sequence
43
+ from dataclasses import dataclass
44
+ from datetime import datetime
45
+ from enum import StrEnum
46
+ from typing import Final
47
+
48
+ from fleetpull.incremental import DateWindow
49
+ from fleetpull.state.database import (
50
+ SqliteScalar,
51
+ StateDatabase,
52
+ expect_int,
53
+ expect_text,
54
+ parse_stored_instant,
55
+ )
56
+ from fleetpull.timing import Clock, to_iso8601
57
+ from fleetpull.vocabulary import Provider
58
+
59
+ __all__: list[str] = [
60
+ 'ClaimedWorkUnit',
61
+ 'WorkUnitProgress',
62
+ 'WorkUnitSpec',
63
+ 'WorkUnitStatus',
64
+ 'WorkUnitStore',
65
+ ]
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ class WorkUnitStatus(StrEnum):
71
+ """
72
+ The ``work_units.status`` lifecycle value.
73
+
74
+ ``pending`` → ``claimed`` → ``done`` / ``failed``; a ``failed`` unit returns to
75
+ ``claimed`` when re-served on a later claim. The store writes and filters
76
+ on this closed set, so centralizing the four literals keeps them out of
77
+ scattered string form. The values equal the schema CHECK literals exactly; the
78
+ two are held in two places by the same boundary discipline as ``CursorKind``
79
+ (the migration runner owns its DDL, the store owns its writes, neither imports
80
+ the other), pinned by the round-trip tests against a real migrated table.
81
+ """
82
+
83
+ PENDING = 'pending'
84
+ CLAIMED = 'claimed'
85
+ DONE = 'done'
86
+ FAILED = 'failed'
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class WorkUnitSpec:
91
+ """
92
+ One backfill unit to enqueue: a date chunk of an endpoint, optionally partitioned.
93
+
94
+ Pure data, no validation — the ``chunk_start < chunk_end`` guard lives in
95
+ :meth:`WorkUnitStore.enqueue`, as with the ledger.
96
+
97
+ Attributes:
98
+ provider: The provider being backfilled.
99
+ endpoint: The endpoint being backfilled.
100
+ partition_key: The opaque per-endpoint partition (a vehicle id, a driver
101
+ id, ...), or ``None`` for an unpartitioned endpoint. The store never
102
+ interprets it.
103
+ chunk_start: The chunk's inclusive start, timezone-aware UTC.
104
+ chunk_end: The chunk's end, timezone-aware UTC; must be after
105
+ ``chunk_start``.
106
+ """
107
+
108
+ provider: Provider
109
+ endpoint: str
110
+ partition_key: str | None
111
+ chunk_start: datetime
112
+ chunk_end: datetime
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class ClaimedWorkUnit:
117
+ """
118
+ A unit handed to a worker by :meth:`WorkUnitStore.claim_next`.
119
+
120
+ Attributes:
121
+ unit_id: The claimed unit's id (the table's rowid alias).
122
+ spec: The unit's identity and chunk, reconstructed from the row.
123
+ attempt_count: The post-increment attempt number of the claim that
124
+ returned this unit (1 on the first claim).
125
+ """
126
+
127
+ unit_id: int
128
+ spec: WorkUnitSpec
129
+ attempt_count: int
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class WorkUnitProgress:
134
+ """
135
+ Per-status unit counts for one (provider, endpoint), from :meth:`WorkUnitStore.progress`.
136
+
137
+ Attributes:
138
+ pending: Units awaiting claim.
139
+ claimed: Units currently in flight.
140
+ done: Units completed successfully.
141
+ failed: Units that failed their last attempt (re-served on a later pass).
142
+ """
143
+
144
+ pending: int
145
+ claimed: int
146
+ done: int
147
+ failed: int
148
+
149
+
150
+ _INSERT_UNIT_SQL: Final[str] = """
151
+ INSERT OR IGNORE INTO work_units (
152
+ provider, endpoint, partition_key, chunk_start, chunk_end
153
+ )
154
+ VALUES (?, ?, ?, ?, ?)
155
+ """
156
+
157
+ _RESET_CLAIMED_SQL: Final[str] = """
158
+ UPDATE work_units
159
+ SET status = ?
160
+ WHERE provider = ? AND endpoint = ? AND status = ?
161
+ """
162
+
163
+ # Lexical >= / <= on ``to_iso8601``'s fixed-width Z-form is chronological
164
+ # (the cursor store's recorded reasoning); only rows FULLY inside the
165
+ # window release -- a straddler stays done, and the re-plan's fresh tiles
166
+ # overlap it harmlessly (delete-by-window refetch is idempotent).
167
+ _RELEASE_DONE_SQL: Final[str] = """
168
+ DELETE FROM work_units
169
+ WHERE provider = ? AND endpoint = ? AND status = ?
170
+ AND chunk_start >= ? AND chunk_end <= ?
171
+ """
172
+
173
+ _CLAIM_NEXT_SQL: Final[str] = """
174
+ UPDATE work_units
175
+ SET status = ?, claimed_at = ?, attempt_count = attempt_count + 1,
176
+ finished_at = NULL, last_error = NULL
177
+ WHERE unit_id = (
178
+ SELECT unit_id FROM work_units
179
+ WHERE provider = ? AND endpoint = ? AND status IN (?, ?)
180
+ ORDER BY unit_id
181
+ LIMIT 1
182
+ )
183
+ RETURNING unit_id, partition_key, chunk_start, chunk_end, attempt_count
184
+ """
185
+
186
+ _MARK_DONE_SQL: Final[str] = """
187
+ UPDATE work_units
188
+ SET status = ?, finished_at = ?, observed_max = ?
189
+ WHERE unit_id = ? AND status = ?
190
+ """
191
+
192
+ # The prefix-advance read (DESIGN section 5, 2026-07-20): the maximum
193
+ # observation across the contiguous done-prefix -- every done unit whose
194
+ # chunk starts before the earliest not-done unit (or all done units when
195
+ # none remains). MAX over ``to_iso8601``'s fixed-width Z-form is
196
+ # chronological; NULL observations (empty units, pre-v3 completions) are
197
+ # ignored by MAX, and an all-NULL prefix returns NULL.
198
+ _DONE_PREFIX_OBSERVATION_SQL: Final[str] = """
199
+ SELECT MAX(observed_max) FROM work_units
200
+ WHERE provider = ? AND endpoint = ? AND status = ?
201
+ AND chunk_start < COALESCE(
202
+ (SELECT MIN(chunk_start) FROM work_units
203
+ WHERE provider = ? AND endpoint = ? AND status != ?),
204
+ '9999-12-31T23:59:59Z'
205
+ )
206
+ """
207
+
208
+ _MARK_FAILED_SQL: Final[str] = """
209
+ UPDATE work_units
210
+ SET status = ?, finished_at = ?, last_error = ?
211
+ WHERE unit_id = ? AND status = ?
212
+ """
213
+
214
+ _PROGRESS_SQL: Final[str] = """
215
+ SELECT status, count(*) FROM work_units
216
+ WHERE provider = ? AND endpoint = ?
217
+ GROUP BY status
218
+ """
219
+
220
+
221
+ def _build_claimed_unit(
222
+ provider: Provider, endpoint: str, row: tuple[SqliteScalar, ...]
223
+ ) -> ClaimedWorkUnit:
224
+ """
225
+ Reconstruct a :class:`ClaimedWorkUnit` from a claim's ``RETURNING`` row.
226
+
227
+ Narrows the row's columns to their STRICT types and parses the chunk bounds
228
+ back from ISO-8601 UTC. The ``spec`` reuses the claim call's ``provider`` /
229
+ ``endpoint`` plus the row's ``partition_key`` and chunks.
230
+
231
+ Args:
232
+ provider: The provider of the claim call (reused in the spec).
233
+ endpoint: The endpoint of the claim call (reused in the spec).
234
+ row: The ``RETURNING`` row: ``(unit_id, partition_key, chunk_start,
235
+ chunk_end, attempt_count)``.
236
+
237
+ Returns:
238
+ The reconstructed claimed unit.
239
+
240
+ Raises:
241
+ ConfigurationError: A stored chunk bound is not parseable ISO-8601 UTC —
242
+ state-store corruption, the cursor-store stance.
243
+ RuntimeError: A column came back with a type the STRICT schema forbids — a
244
+ SQLite contract violation, surfaced loudly.
245
+ """
246
+ raw_unit_id, raw_partition_key, raw_chunk_start, raw_chunk_end, raw_attempts = row
247
+ unit_id = expect_int(raw_unit_id, 'work_units.unit_id')
248
+ partition_key = (
249
+ None
250
+ if raw_partition_key is None
251
+ else expect_text(raw_partition_key, 'work_units.partition_key')
252
+ )
253
+ spec: WorkUnitSpec = WorkUnitSpec(
254
+ provider=provider,
255
+ endpoint=endpoint,
256
+ partition_key=partition_key,
257
+ chunk_start=parse_stored_instant(
258
+ expect_text(raw_chunk_start, 'work_units.chunk_start'),
259
+ provider=provider,
260
+ endpoint=endpoint,
261
+ column=f'work-unit chunk_start (unit {unit_id})',
262
+ ),
263
+ chunk_end=parse_stored_instant(
264
+ expect_text(raw_chunk_end, 'work_units.chunk_end'),
265
+ provider=provider,
266
+ endpoint=endpoint,
267
+ column=f'work-unit chunk_end (unit {unit_id})',
268
+ ),
269
+ )
270
+ return ClaimedWorkUnit(
271
+ unit_id=unit_id,
272
+ spec=spec,
273
+ attempt_count=expect_int(raw_attempts, 'work_units.attempt_count'),
274
+ )
275
+
276
+
277
+ class WorkUnitStore:
278
+ """
279
+ The backfill claim queue: enqueue, atomic claim, completion, and crash recovery.
280
+
281
+ Dumb persistence plus an atomic claim (DESIGN §5). The caller plans the
282
+ decomposition and drives enqueue/claim/execute/complete; the store only stores
283
+ and serves units, knowing nothing about HTTP, parquet, chunking, or what a
284
+ ``partition_key`` means. Runs after ``migrate_to_head`` (the ``work_units``
285
+ table must exist).
286
+
287
+ Args:
288
+ database: The initialized, migrated state database supplying connections.
289
+ clock: The clock stamping ``claimed_at`` and ``finished_at``.
290
+ """
291
+
292
+ def __init__(self, database: StateDatabase, clock: Clock) -> None:
293
+ self._database: StateDatabase = database
294
+ self._clock: Clock = clock
295
+
296
+ def enqueue(self, units: Sequence[WorkUnitSpec]) -> int:
297
+ """
298
+ Insert work units idempotently, returning the number newly inserted.
299
+
300
+ ``INSERT OR IGNORE`` on the natural key (provider, endpoint, partition_key,
301
+ and the full window): re-enqueuing an existing unit inserts nothing, so a
302
+ re-run of a backfill plan never duplicates units. A same-start /
303
+ different-end unit is distinct (the store makes no overlap judgment — that
304
+ is the caller's concern).
305
+
306
+ Args:
307
+ units: The units to enqueue; each must have ``chunk_start`` strictly
308
+ before ``chunk_end``.
309
+
310
+ Returns:
311
+ The count of units actually inserted (existing ones are ignored).
312
+
313
+ Raises:
314
+ ValueError: A unit has ``chunk_start`` not strictly before
315
+ ``chunk_end``, or a chunk bound is naive or not UTC (surfaced from
316
+ the timing codec) — caller bugs, kept stdlib.
317
+
318
+ Side Effects:
319
+ Opens a connection, inserts the new rows, and commits.
320
+ """
321
+ rows: list[tuple[str, str, str | None, str, str]] = []
322
+ for unit in units:
323
+ if unit.chunk_start >= unit.chunk_end:
324
+ raise ValueError('chunk_start must be strictly before chunk_end')
325
+ rows.append(
326
+ (
327
+ unit.provider.value,
328
+ unit.endpoint,
329
+ unit.partition_key,
330
+ to_iso8601(unit.chunk_start),
331
+ to_iso8601(unit.chunk_end),
332
+ )
333
+ )
334
+ with self._database.transaction() as connection:
335
+ changes_before: int = connection.total_changes
336
+ connection.executemany(_INSERT_UNIT_SQL, rows)
337
+ inserted: int = connection.total_changes - changes_before
338
+ logger.debug('enqueued %s work units (%s new)', len(rows), inserted)
339
+ return inserted
340
+
341
+ def release_done_units(
342
+ self, provider: Provider, endpoint: str, *, window: DateWindow
343
+ ) -> int:
344
+ """Delete ``done`` units lying fully inside a window about to be re-planned.
345
+
346
+ The planner's half of the release-then-enqueue pairing (module
347
+ docstring): a ``done`` row is committed history, and when the
348
+ planner deliberately re-covers its span (the lookback margin
349
+ refetch), the row must release back to plannable -- otherwise the
350
+ idempotent enqueue collapses onto it and the re-covered day is
351
+ never fetched again. Run provenance is the runs ledger's; a unit
352
+ row inside a window being re-planned is live work, not archive.
353
+
354
+ Sound at exactly one call site -- residual planning, which runs
355
+ only after the claim loop drained (so every row is ``done``) and
356
+ immediately re-tiles the released span hole-free, preserving the
357
+ prefix rule's gap-unreachability (DESIGN §5's re-derivation,
358
+ 2026-07-21). A crash between release and enqueue is safe: the
359
+ watermark is untouched, so the next run resolves the identical
360
+ residual and plans it fresh. Only rows FULLY inside the window
361
+ release; a straddling ``done`` row stays, harmlessly overlapped
362
+ by the fresh tiles (refetch is idempotent per partition).
363
+
364
+ Args:
365
+ provider: The provider whose units to release.
366
+ endpoint: The endpoint whose units to release.
367
+ window: The residual window the caller is about to re-plan.
368
+
369
+ Returns:
370
+ The count of released (deleted) rows.
371
+
372
+ Side Effects:
373
+ Opens a connection, deletes the released rows, and commits.
374
+ """
375
+ with self._database.transaction() as connection:
376
+ changes_before: int = connection.total_changes
377
+ connection.execute(
378
+ _RELEASE_DONE_SQL,
379
+ (
380
+ provider.value,
381
+ endpoint,
382
+ WorkUnitStatus.DONE.value,
383
+ to_iso8601(window.start),
384
+ to_iso8601(window.end),
385
+ ),
386
+ )
387
+ released: int = connection.total_changes - changes_before
388
+ logger.debug('released %s done work units for re-planning', released)
389
+ return released
390
+
391
+ def reset_claimed_to_pending(self, provider: Provider, endpoint: str) -> int:
392
+ """
393
+ Revert every ``claimed`` unit to ``pending`` (startup crash recovery).
394
+
395
+ Called once at backfill startup per endpoint: with no workers live yet, any
396
+ ``claimed`` row is stale (its worker died in a prior crashed invocation), so
397
+ reverting it is safe — no lease, no heartbeat. ``attempt_count`` is left
398
+ untouched, so a crash-looping unit's attempts stay recorded. ``done`` /
399
+ ``failed`` / ``pending`` rows are not touched.
400
+
401
+ Args:
402
+ provider: The provider whose units to reset.
403
+ endpoint: The endpoint whose units to reset.
404
+
405
+ Returns:
406
+ The number of units reverted from ``claimed`` to ``pending``.
407
+
408
+ Side Effects:
409
+ Opens a connection, updates the matching rows, and commits.
410
+ """
411
+ with self._database.transaction() as connection:
412
+ reset_count: int = connection.execute(
413
+ _RESET_CLAIMED_SQL,
414
+ (
415
+ WorkUnitStatus.PENDING.value,
416
+ provider.value,
417
+ endpoint,
418
+ WorkUnitStatus.CLAIMED.value,
419
+ ),
420
+ ).rowcount
421
+ logger.debug(
422
+ 'reset %s claimed work units to pending: provider=%s endpoint=%s',
423
+ reset_count,
424
+ provider.value,
425
+ endpoint,
426
+ )
427
+ return reset_count
428
+
429
+ def claim_next(self, provider: Provider, endpoint: str) -> ClaimedWorkUnit | None:
430
+ """
431
+ Atomically claim the lowest claimable unit, or return ``None``.
432
+
433
+ Claims the lowest-``unit_id`` (FIFO) ``pending`` or ``failed`` unit, in
434
+ one atomic ``UPDATE ... RETURNING`` that flips it to ``claimed``,
435
+ increments ``attempt_count`` (recorded and narrated; a crash
436
+ mid-execution still counts), and clears the prior attempt's
437
+ ``finished_at`` / ``last_error``. There is deliberately no attempt cap
438
+ (the module docstring carries the fail-loud rationale). Safe under
439
+ concurrency without an app-level lock: WAL serializes writers, so a
440
+ competing claim's subquery re-evaluates only after this one commits.
441
+
442
+ Args:
443
+ provider: The provider whose queue to claim from.
444
+ endpoint: The endpoint whose queue to claim from.
445
+
446
+ Returns:
447
+ The claimed unit, or ``None`` when nothing is claimable.
448
+
449
+ Raises:
450
+ ConfigurationError: The claimed row holds an unparseable chunk —
451
+ state-store corruption.
452
+ RuntimeError: A claimed column came back with a forbidden type — a
453
+ SQLite contract violation.
454
+
455
+ Side Effects:
456
+ Opens a connection, claims one row, and commits.
457
+ """
458
+ claimed_at: str = to_iso8601(self._clock.now_utc())
459
+ with self._database.transaction() as connection:
460
+ row = connection.execute(
461
+ _CLAIM_NEXT_SQL,
462
+ (
463
+ WorkUnitStatus.CLAIMED.value,
464
+ claimed_at,
465
+ provider.value,
466
+ endpoint,
467
+ WorkUnitStatus.PENDING.value,
468
+ WorkUnitStatus.FAILED.value,
469
+ ),
470
+ ).fetchone()
471
+ if row is None:
472
+ return None
473
+ claimed_unit: ClaimedWorkUnit = _build_claimed_unit(provider, endpoint, row)
474
+ logger.debug(
475
+ 'claimed work unit: unit_id=%s attempt=%s',
476
+ claimed_unit.unit_id,
477
+ claimed_unit.attempt_count,
478
+ )
479
+ return claimed_unit
480
+
481
+ def mark_done(self, unit_id: int, *, observed_max: datetime | None) -> None:
482
+ """
483
+ Mark a claimed unit ``done``, recording its folded observation.
484
+
485
+ Only a ``claimed`` unit may be completed; the guarded UPDATE matches no row
486
+ otherwise. ``observed_max`` is the unit's folded in-window maximum event
487
+ time — the prefix-advance watermark rule's datum
488
+ (``done_prefix_observation`` reads it); ``None`` records an empty unit,
489
+ which the prefix read ignores.
490
+
491
+ Args:
492
+ unit_id: The claimed unit to complete.
493
+ observed_max: The unit's folded in-window maximum event time, or
494
+ ``None`` when the unit observed no in-window event.
495
+
496
+ Raises:
497
+ ValueError: No ``claimed`` unit has this ``unit_id`` (it does not exist
498
+ or is not currently claimed) — a caller bug, kept stdlib; or a
499
+ naive / non-UTC ``observed_max`` (surfaced from the timing codec).
500
+
501
+ Side Effects:
502
+ Opens a connection, updates the row, and commits.
503
+ """
504
+ finished_at: str = to_iso8601(self._clock.now_utc())
505
+ serialized_observation: str | None = (
506
+ to_iso8601(observed_max) if observed_max is not None else None
507
+ )
508
+ with self._database.transaction() as connection:
509
+ affected: int = connection.execute(
510
+ _MARK_DONE_SQL,
511
+ (
512
+ WorkUnitStatus.DONE.value,
513
+ finished_at,
514
+ serialized_observation,
515
+ unit_id,
516
+ WorkUnitStatus.CLAIMED.value,
517
+ ),
518
+ ).rowcount
519
+ if affected == 0:
520
+ raise ValueError(
521
+ f'no claimed work unit with unit_id {unit_id} to mark done'
522
+ )
523
+ logger.debug('marked work unit done: unit_id=%s', unit_id)
524
+
525
+ def done_prefix_observation(
526
+ self, provider: Provider, endpoint: str
527
+ ) -> datetime | None:
528
+ """
529
+ The maximum observation across the contiguous done-prefix.
530
+
531
+ The prefix-advance watermark rule's read (DESIGN §5, 2026-07-20): over
532
+ this endpoint's units ordered by ``chunk_start``, take every ``done``
533
+ unit before the earliest not-done one (all of them when none remains)
534
+ and return the maximum recorded ``observed_max``. Out-of-order parallel
535
+ completions beyond a gap contribute nothing until the gap closes, so a
536
+ watermark advanced to this value never overstates coverage. The query
537
+ is gap-blind by design: it sees only rows, so a hole no row represents
538
+ — a never-enqueued window between done units — would not gate the
539
+ prefix. Soundness therefore rests on the four §5 invariants (one
540
+ enqueue site running only after the claim loop drains, the capless
541
+ always-claimable queue, row deletion confined to the planning
542
+ site's release-then-enqueue pairing which immediately re-tiles the
543
+ released span, hole-free planner tiling), which make such a hole
544
+ unreachable; the state-layer gap-blindness test pins this
545
+ dependence.
546
+
547
+ Args:
548
+ provider: The provider whose units to read.
549
+ endpoint: The endpoint whose units to read.
550
+
551
+ Returns:
552
+ The prefix's maximum observation, or ``None`` when the prefix is
553
+ empty or holds only empty units.
554
+
555
+ Raises:
556
+ ConfigurationError: The stored observation is unparseable —
557
+ state-store corruption, the store's uniform stance.
558
+ RuntimeError: The observation column came back non-text,
559
+ violating the STRICT schema contract.
560
+
561
+ Side Effects:
562
+ Opens a connection and reads.
563
+ """
564
+ done = WorkUnitStatus.DONE.value
565
+ with self._database.connect() as connection:
566
+ row: tuple[SqliteScalar] | None = connection.execute(
567
+ _DONE_PREFIX_OBSERVATION_SQL,
568
+ (provider.value, endpoint, done, provider.value, endpoint, done),
569
+ ).fetchone()
570
+ observation = row[0] if row is not None else None
571
+ if observation is None:
572
+ return None
573
+ return parse_stored_instant(
574
+ expect_text(observation, 'work_units.observed_max'),
575
+ provider=provider,
576
+ endpoint=endpoint,
577
+ column='work-unit observed_max',
578
+ )
579
+
580
+ def mark_failed(self, unit_id: int, *, error_detail: str) -> None:
581
+ """
582
+ Mark a claimed unit ``failed`` with an error detail.
583
+
584
+ Only a ``claimed`` unit may be failed. ``attempt_count`` is not touched here
585
+ — it was already incremented at claim — so the attempt record stays true.
586
+
587
+ Args:
588
+ unit_id: The claimed unit to fail.
589
+ error_detail: Human-readable failure context recorded on the row.
590
+
591
+ Raises:
592
+ ValueError: No ``claimed`` unit has this ``unit_id`` — a caller bug,
593
+ kept stdlib.
594
+
595
+ Side Effects:
596
+ Opens a connection, updates the row, and commits.
597
+ """
598
+ finished_at: str = to_iso8601(self._clock.now_utc())
599
+ with self._database.transaction() as connection:
600
+ affected: int = connection.execute(
601
+ _MARK_FAILED_SQL,
602
+ (
603
+ WorkUnitStatus.FAILED.value,
604
+ finished_at,
605
+ error_detail,
606
+ unit_id,
607
+ WorkUnitStatus.CLAIMED.value,
608
+ ),
609
+ ).rowcount
610
+ if affected == 0:
611
+ raise ValueError(
612
+ f'no claimed work unit with unit_id {unit_id} to mark failed'
613
+ )
614
+ logger.debug('marked work unit failed: unit_id=%s', unit_id)
615
+
616
+ def progress(self, provider: Provider, endpoint: str) -> WorkUnitProgress:
617
+ """
618
+ Return per-status unit counts for one (provider, endpoint).
619
+
620
+ Args:
621
+ provider: The provider whose units to count.
622
+ endpoint: The endpoint whose units to count.
623
+
624
+ Returns:
625
+ The counts, with statuses absent from the queue reported as ``0``.
626
+
627
+ Raises:
628
+ RuntimeError: A grouped ``status`` or count came back with a forbidden
629
+ type — a SQLite contract violation.
630
+
631
+ Side Effects:
632
+ Opens a connection and reads the grouped counts.
633
+ """
634
+ with self._database.connect() as connection:
635
+ grouped_rows = connection.execute(
636
+ _PROGRESS_SQL, (provider.value, endpoint)
637
+ ).fetchall()
638
+ counts: dict[str, int] = {}
639
+ for status_value, count_value in grouped_rows:
640
+ counts[expect_text(status_value, 'work_units.status')] = expect_int(
641
+ count_value, 'work-unit count'
642
+ )
643
+ return WorkUnitProgress(
644
+ pending=counts.get(WorkUnitStatus.PENDING.value, 0),
645
+ claimed=counts.get(WorkUnitStatus.CLAIMED.value, 0),
646
+ done=counts.get(WorkUnitStatus.DONE.value, 0),
647
+ failed=counts.get(WorkUnitStatus.FAILED.value, 0),
648
+ )
@@ -0,0 +1,30 @@
1
+ # src/fleetpull/storage/__init__.py
2
+ """The storage layer: write a records DataFrame to parquet.
3
+
4
+ ``select_writer`` returns the ``DatasetWriter`` for an endpoint's storage-kind /
5
+ sync-mode cell; the orchestrator drives it (``write`` per piece, then
6
+ ``finalize``). Stateless: parquet only, no SQLite and no watermark commit (the
7
+ orchestrator sequences those). ``WriteResult`` is the write report; ``in_window``
8
+ is the half-open ``[start, end)`` window-membership predicate the orchestrator
9
+ filters watermark batches with. ``MetadataSnapshot`` with its render/write pair
10
+ is the per-endpoint ``metadata.json`` projection the orchestrator writes after a
11
+ successful run (DESIGN §3)."""
12
+
13
+ from fleetpull.storage.frames import in_window
14
+ from fleetpull.storage.metadata import (
15
+ MetadataSnapshot,
16
+ render_metadata_json,
17
+ write_metadata_json,
18
+ )
19
+ from fleetpull.storage.result import WriteResult
20
+ from fleetpull.storage.writers import DatasetWriter, select_writer
21
+
22
+ __all__: list[str] = [
23
+ 'DatasetWriter',
24
+ 'MetadataSnapshot',
25
+ 'WriteResult',
26
+ 'in_window',
27
+ 'render_metadata_json',
28
+ 'select_writer',
29
+ 'write_metadata_json',
30
+ ]