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,572 @@
1
+ # src/fleetpull/state/run_ledger.py
2
+ """The run ledger: the operational record of every fetch and the coverage frontier.
3
+
4
+ One row per run — one fetch of one (provider, endpoint) in one of three sync modes:
5
+ a *snapshot* (no range — a full current-state refetch), a *watermark* window
6
+ (``window_start``/``window_end``), or a *feed* version range
7
+ (``from_version``/``to_version``). A ``mode`` column records which, so the row is
8
+ self-describing; the range columns a run populates follow its mode. A sync
9
+ invocation produces many runs; incremental and backfill-chunk fetches alike record
10
+ one, so the ledger is the single coverage source (DESIGN §5). Runs after
11
+ ``migrate_to_head`` — the ``runs`` table must already exist.
12
+
13
+ Two-phase lifecycle: one of ``start_snapshot_run`` / ``start_window_run`` /
14
+ ``start_feed_run`` inserts a ``running`` row stamped from the injected ``Clock``
15
+ with the range shape its mode requires — three single-shape entry points, so an
16
+ impossible arm combination cannot be expressed; ``complete_run`` closes it
17
+ ``succeeded`` with the row count (and a feed run's end ``toVersion``); ``fail_run``
18
+ closes it ``failed`` with an error detail. The mode-keyed arm shape, a non-negative
19
+ row count, and a well-ordered window are guarded both here (each entry point owns
20
+ its shape) and by the table's CHECK constraints (the structural backstop),
21
+ mirroring the cursor store's two-places-by-discipline split.
22
+
23
+ ``coverage_frontier`` reads ``max(window_end)`` over an endpoint's ``succeeded``
24
+ runs — the implementation of DESIGN §4/§5 resume arm (2). It is watermark-only:
25
+ feed and snapshot endpoints never reach this arm (a feed endpoint holds a
26
+ committed cursor; a snapshot has no resume). A stored
27
+ ``window_end`` that is not parseable ISO-8601 UTC is state-store corruption and
28
+ raises ``ConfigurationError``, the same stance as the cursor store. A crashed
29
+ run's stale ``running`` row is diagnostic only — the frontier filters
30
+ ``succeeded`` — and reaping it is deferred.
31
+ """
32
+
33
+ import logging
34
+ import sqlite3
35
+ from dataclasses import dataclass
36
+ from datetime import datetime
37
+ from enum import StrEnum
38
+ from typing import Final
39
+
40
+ from fleetpull.exceptions import ConfigurationError
41
+ from fleetpull.incremental import DateWindow
42
+ from fleetpull.state.database import (
43
+ SqliteScalar,
44
+ StateDatabase,
45
+ expect_text,
46
+ parse_stored_instant,
47
+ )
48
+ from fleetpull.timing import Clock, to_iso8601
49
+ from fleetpull.vocabulary import Provider
50
+
51
+ __all__: list[str] = ['RunLedger', 'RunMode', 'RunStatus']
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ class RunStatus(StrEnum):
57
+ """
58
+ The ``runs.status`` lifecycle value: ``running`` then ``succeeded`` / ``failed``.
59
+
60
+ The store writes and filters on this closed set, so centralizing the three
61
+ literals keeps them out of scattered string form. The values equal the schema
62
+ CHECK literals exactly; the two are held in two places by the same boundary
63
+ discipline as ``CursorKind`` (the migration runner owns its DDL, the store owns
64
+ its writes, neither imports the other), pinned by the round-trip tests against a
65
+ real migrated table with the CHECK active.
66
+ """
67
+
68
+ RUNNING = 'running'
69
+ SUCCEEDED = 'succeeded'
70
+ FAILED = 'failed'
71
+
72
+
73
+ class RunMode(StrEnum):
74
+ """
75
+ The ``runs.mode`` discriminator: which sync mode produced the run.
76
+
77
+ The persisted shadow of the endpoints layer's ``SyncMode`` variant —
78
+ ``SnapshotMode`` -> ``SNAPSHOT``, ``WatermarkMode`` -> ``WATERMARK``,
79
+ ``FeedMode`` -> ``FEED`` — recorded so the row is self-describing: a reader
80
+ knows which range columns to expect without inferring from null patterns, and
81
+ ``complete_run`` dispatches on it (only a feed run carries a ``to_version``). A
82
+ StrEnum here, not the ``SyncMode`` union: ``SyncMode`` carries config
83
+ (``WatermarkMode``'s lookback) and lives in ``endpoints/``, above ``state/``,
84
+ so the ledger cannot import it; the orchestrator translates ``SyncMode`` to this
85
+ tag when it records the run. The values equal the schema CHECK literals exactly,
86
+ held in two places by the same boundary discipline as ``RunStatus`` and
87
+ ``CursorKind``.
88
+ """
89
+
90
+ SNAPSHOT = 'snapshot'
91
+ WATERMARK = 'watermark'
92
+ FEED = 'feed'
93
+
94
+
95
+ _INSERT_RUN_SQL: Final[str] = """
96
+ INSERT INTO runs (
97
+ provider, endpoint, status, mode,
98
+ window_start, window_end, from_version, started_at
99
+ )
100
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
101
+ """
102
+
103
+ _SELECT_RUN_MODE_SQL: Final[str] = 'SELECT mode FROM runs WHERE run_id = ?'
104
+
105
+ _COMPLETE_NONFEED_RUN_SQL: Final[str] = """
106
+ UPDATE runs
107
+ SET status = ?, ended_at = ?, row_count = ?
108
+ WHERE run_id = ?
109
+ """
110
+
111
+ _COMPLETE_FEED_RUN_SQL: Final[str] = """
112
+ UPDATE runs
113
+ SET status = ?, ended_at = ?, row_count = ?, to_version = ?
114
+ WHERE run_id = ?
115
+ """
116
+
117
+ _FAIL_RUN_SQL: Final[str] = """
118
+ UPDATE runs
119
+ SET status = ?, ended_at = ?, error_detail = ?
120
+ WHERE run_id = ?
121
+ """
122
+
123
+ _COVERAGE_FRONTIER_SQL: Final[str] = """
124
+ SELECT max(window_end) FROM runs
125
+ WHERE provider = ? AND endpoint = ? AND status = ? AND window_end IS NOT NULL
126
+ """
127
+
128
+ _LAST_SUCCESS_SQL: Final[str] = """
129
+ SELECT max(ended_at) FROM runs
130
+ WHERE provider = ? AND endpoint = ? AND status = ?
131
+ """
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class _RunRange:
136
+ """
137
+ The mode-keyed range columns a ``runs`` row carries at insert.
138
+
139
+ The three ``start_*_run`` entry points each build the range shape their mode
140
+ requires — a watermark window, a feed start version, or (snapshot) nothing —
141
+ and hand it to :meth:`RunLedger._insert_run` as one value instead of three
142
+ parallel parameters. All fields default to ``None``; the table's mode-keyed
143
+ CHECK is the structural backstop on the combination.
144
+
145
+ Attributes:
146
+ window_start_text: Serialized watermark window start, or ``None``.
147
+ window_end_text: Serialized watermark window end, or ``None``.
148
+ from_version: Feed start version, or ``None``.
149
+ """
150
+
151
+ window_start_text: str | None = None
152
+ window_end_text: str | None = None
153
+ from_version: str | None = None
154
+
155
+
156
+ def _read_run_mode(connection: sqlite3.Connection, run_id: int) -> RunMode:
157
+ """
158
+ Read a run's stored ``mode`` and narrow it to a :class:`RunMode`.
159
+
160
+ The read half of :meth:`RunLedger.complete_run`'s mode dispatch, on the
161
+ caller's already-open connection so the read and the closing UPDATE share
162
+ one transaction.
163
+
164
+ Args:
165
+ connection: An open state-database connection; nothing is committed here.
166
+ run_id: The run whose mode to read.
167
+
168
+ Returns:
169
+ The stored mode as a :class:`RunMode`.
170
+
171
+ Raises:
172
+ ValueError: No run has this ``run_id`` — a caller bug, kept stdlib.
173
+ ConfigurationError: The stored ``mode`` is not a recognized ``RunMode``
174
+ — state-store corruption, the same stance as the cursor store.
175
+ RuntimeError: The stored ``mode`` came back non-text, violating the
176
+ STRICT ``TEXT`` schema contract.
177
+
178
+ Side Effects:
179
+ Reads one row on ``connection``.
180
+ """
181
+ mode_row = connection.execute(_SELECT_RUN_MODE_SQL, (run_id,)).fetchone()
182
+ if mode_row is None:
183
+ raise ValueError(f'no run with run_id {run_id}')
184
+ stored_mode: str = expect_text(mode_row[0], 'runs.mode')
185
+ try:
186
+ return RunMode(stored_mode)
187
+ except ValueError as error:
188
+ raise ConfigurationError(
189
+ 'state database holds an unrecognized run mode',
190
+ detail=(
191
+ f'run {run_id} mode {stored_mode!r} is not one of '
192
+ f'{[member.value for member in RunMode]}'
193
+ ),
194
+ ) from error
195
+
196
+
197
+ def _max_over_succeeded_runs(
198
+ database: StateDatabase,
199
+ sql: str,
200
+ provider: Provider,
201
+ endpoint: str,
202
+ *,
203
+ column: str,
204
+ ) -> datetime | None:
205
+ """
206
+ Run a ``max(...)``-over-``succeeded``-runs aggregate and parse its instant.
207
+
208
+ The shared tail of :meth:`RunLedger.coverage_frontier` and
209
+ :meth:`RunLedger.last_success_at`: execute ``sql`` bound to (provider,
210
+ endpoint, ``succeeded``), narrow the aggregate's single nullable TEXT
211
+ column, and parse it from ISO-8601 UTC. ``column`` names the aggregated
212
+ ``runs`` column in the raised errors, so each caller keeps its own prose
213
+ (``window_end`` / ``ended_at``).
214
+
215
+ Args:
216
+ database: The state database supplying the connection.
217
+ sql: The aggregate query; must bind exactly ``(provider, endpoint,
218
+ status)`` and return one row with one nullable TEXT column.
219
+ provider: The provider whose runs to aggregate.
220
+ endpoint: The endpoint whose runs to aggregate.
221
+ column: The aggregated ``runs`` column's name, used in error prose.
222
+
223
+ Returns:
224
+ The parsed UTC datetime, or ``None`` when the aggregate's column is
225
+ NULL (no succeeded run qualifies).
226
+
227
+ Raises:
228
+ ConfigurationError: The stored value is not parseable ISO-8601 UTC —
229
+ state-store corruption, the same stance as the cursor store.
230
+ RuntimeError: The aggregated value came back non-text, violating the
231
+ STRICT ``TEXT`` schema contract.
232
+
233
+ Side Effects:
234
+ Opens a connection and reads one aggregate row.
235
+ """
236
+ with database.connect() as connection:
237
+ aggregate_row = connection.execute(
238
+ sql, (provider.value, endpoint, RunStatus.SUCCEEDED.value)
239
+ ).fetchone()
240
+ # A bare max() always returns exactly one row; its single column is NULL
241
+ # when no succeeded run qualifies.
242
+ instant_text: SqliteScalar = aggregate_row[0]
243
+ if instant_text is None:
244
+ return None
245
+ return parse_stored_instant(
246
+ expect_text(instant_text, f'runs.{column}'),
247
+ provider=provider,
248
+ endpoint=endpoint,
249
+ column=f'run {column}',
250
+ )
251
+
252
+
253
+ class RunLedger:
254
+ """
255
+ Records each fetch run and answers the coverage frontier (DESIGN §5).
256
+
257
+ One row per run — one fetch of one (provider, endpoint) in one of three sync
258
+ modes (snapshot/watermark/feed), recorded in a ``mode`` column. The two-phase
259
+ lifecycle is :meth:`start_snapshot_run` / :meth:`start_window_run` /
260
+ :meth:`start_feed_run` → :meth:`complete_run` / :meth:`fail_run`. The mode-keyed
261
+ arm shape, a non-negative row count, and a well-ordered window are enforced both
262
+ by the per-mode entry points here and by the table's CHECK constraints (the
263
+ structural backstop). :meth:`coverage_frontier` is the watermark-only
264
+ implementation of resume arm (2); feed and snapshot endpoints never reach it.
265
+ Runs after ``migrate_to_head`` (the ``runs`` table must exist).
266
+ ``row_count`` uniformly means "records the run produced"; the sink those
267
+ records landed in (a roster for a coordinator harvest, parquet for a
268
+ runner-driven fetch) follows from the run's mode and origin.
269
+
270
+ Args:
271
+ database: The initialized, migrated state database supplying connections.
272
+ clock: The clock stamping ``started_at`` and ``ended_at``.
273
+ """
274
+
275
+ def __init__(self, database: StateDatabase, clock: Clock) -> None:
276
+ self._database: StateDatabase = database
277
+ self._clock: Clock = clock
278
+
279
+ def start_snapshot_run(self, provider: Provider, endpoint: str) -> int:
280
+ """
281
+ Open a snapshot run: a ``running`` row with no range (``mode='snapshot'``).
282
+
283
+ A snapshot re-fetches the endpoint's full current state every run, so the
284
+ row carries no window and no version. The mode-keyed CHECK is the structural
285
+ backstop.
286
+
287
+ Args:
288
+ provider: The provider being fetched.
289
+ endpoint: The endpoint being fetched.
290
+
291
+ Returns:
292
+ The new run's ``run_id`` (the table's rowid alias).
293
+
294
+ Raises:
295
+ RuntimeError: The INSERT returned no ``lastrowid`` — a SQLite contract
296
+ violation, surfaced loudly.
297
+
298
+ Side Effects:
299
+ Opens a connection, inserts one row, and commits.
300
+ """
301
+ return self._insert_run(provider, endpoint, RunMode.SNAPSHOT, _RunRange())
302
+
303
+ def start_window_run(
304
+ self, provider: Provider, endpoint: str, *, window: DateWindow
305
+ ) -> int:
306
+ """
307
+ Open a watermark run over ``window`` (``mode='watermark'``).
308
+
309
+ ``window`` is the half-open resume window the fetch covers, serialized
310
+ via the timing codec. Well-orderedness is the ``DateWindow``'s own
311
+ construction invariant (``start < end``), so no ordering re-check
312
+ happens here; the table's CHECK remains the structural backstop.
313
+
314
+ Args:
315
+ provider: The provider being fetched.
316
+ endpoint: The endpoint being fetched.
317
+ window: The run's half-open resume window, both bounds UTC.
318
+
319
+ Returns:
320
+ The new run's ``run_id`` (the table's rowid alias).
321
+
322
+ Raises:
323
+ ValueError: A bound is naive or not UTC (surfaced from the timing
324
+ codec) — a caller bug, kept stdlib.
325
+ RuntimeError: The INSERT returned no ``lastrowid``.
326
+
327
+ Side Effects:
328
+ Opens a connection, inserts one row, and commits.
329
+ """
330
+ return self._insert_run(
331
+ provider,
332
+ endpoint,
333
+ RunMode.WATERMARK,
334
+ _RunRange(
335
+ window_start_text=to_iso8601(window.start),
336
+ window_end_text=to_iso8601(window.end),
337
+ ),
338
+ )
339
+
340
+ def start_feed_run(
341
+ self, provider: Provider, endpoint: str, *, from_version: str
342
+ ) -> int:
343
+ """
344
+ Open a feed run resuming from ``from_version`` (``mode='feed'``).
345
+
346
+ ``from_version`` is the feed arm's opaque start token, stored verbatim
347
+ (fleetpull never parses it) — or, on the seeded first run (which has
348
+ no token yet, while the table requires a non-null ``from_version`` on
349
+ a feed row), the runner's self-describing ``seed:<iso8601>`` marker
350
+ naming the ``search.fromDate`` the run seeded from. Never read back
351
+ by the program; the marker is run provenance, exactly like the rest
352
+ of the row. The run's end ``toVersion`` is recorded later by
353
+ :meth:`complete_run`.
354
+
355
+ Args:
356
+ provider: The provider being fetched.
357
+ endpoint: The endpoint being fetched.
358
+ from_version: The feed arm's opaque start version, or the seeded
359
+ first run's ``seed:<iso8601>`` marker.
360
+
361
+ Returns:
362
+ The new run's ``run_id`` (the table's rowid alias).
363
+
364
+ Raises:
365
+ RuntimeError: The INSERT returned no ``lastrowid``.
366
+
367
+ Side Effects:
368
+ Opens a connection, inserts one row, and commits.
369
+ """
370
+ return self._insert_run(
371
+ provider, endpoint, RunMode.FEED, _RunRange(from_version=from_version)
372
+ )
373
+
374
+ def _insert_run(
375
+ self, provider: Provider, endpoint: str, mode: RunMode, run_range: _RunRange
376
+ ) -> int:
377
+ """
378
+ Insert one ``running`` row and return its ``run_id``.
379
+
380
+ The shared tail of the three ``start_*_run`` entry points: each builds the
381
+ ``_RunRange`` its mode requires, then delegates the stamp, insert, and
382
+ ``lastrowid`` check here. The mode-keyed range shape is the caller's
383
+ responsibility (and the CHECK's backstop); this helper persists what it is
384
+ given.
385
+
386
+ Raises:
387
+ RuntimeError: The INSERT returned no ``lastrowid`` — a SQLite contract
388
+ violation, surfaced loudly.
389
+ """
390
+ started_at: str = to_iso8601(self._clock.now_utc())
391
+ with self._database.transaction() as connection:
392
+ run_id: int | None = connection.execute(
393
+ _INSERT_RUN_SQL,
394
+ (
395
+ provider.value,
396
+ endpoint,
397
+ RunStatus.RUNNING.value,
398
+ mode.value,
399
+ run_range.window_start_text,
400
+ run_range.window_end_text,
401
+ run_range.from_version,
402
+ started_at,
403
+ ),
404
+ ).lastrowid
405
+ if run_id is None:
406
+ raise RuntimeError('runs INSERT returned no lastrowid')
407
+ logger.debug(
408
+ 'started run: run_id=%s provider=%s endpoint=%s mode=%s',
409
+ run_id,
410
+ provider.value,
411
+ endpoint,
412
+ mode.value,
413
+ )
414
+ return run_id
415
+
416
+ def complete_run(
417
+ self, run_id: int, *, row_count: int, to_version: str | None = None
418
+ ) -> None:
419
+ """
420
+ Close a run ``succeeded`` with its row count (and a feed run's ``toVersion``).
421
+
422
+ Reads the run's ``mode`` and refuses to cross it: snapshot and watermark
423
+ runs reject a ``to_version``, a feed run requires one. The mode-keyed range
424
+ CHECK is the structural backstop behind these guards.
425
+
426
+ Args:
427
+ run_id: The run to close, from one of the ``start_*_run`` methods.
428
+ row_count: Rows fetched for the run; must be non-negative (zero is a
429
+ valid empty fetch).
430
+ to_version: The feed arm's end version — required for a feed run,
431
+ refused for a snapshot or watermark run.
432
+
433
+ Raises:
434
+ ValueError: ``row_count`` is negative; ``run_id`` is unknown; a feed run
435
+ was not given a ``to_version``; or a snapshot/watermark run was —
436
+ all caller bugs, kept stdlib.
437
+ ConfigurationError: the stored ``mode`` is not a recognized ``RunMode``
438
+ — state-store corruption, the same stance as the cursor store.
439
+ RuntimeError: the stored ``mode`` came back non-text, violating the
440
+ STRICT ``TEXT`` schema contract.
441
+
442
+ Side Effects:
443
+ Opens a connection, reads the run's mode, updates the row, and commits.
444
+ """
445
+ if row_count < 0:
446
+ raise ValueError(f'row_count must be non-negative, got {row_count}')
447
+ ended_at: str = to_iso8601(self._clock.now_utc())
448
+ with self._database.transaction() as connection:
449
+ mode: RunMode = _read_run_mode(connection, run_id)
450
+ match mode:
451
+ case RunMode.FEED:
452
+ if to_version is None:
453
+ raise ValueError(
454
+ 'feed runs must record to_version on completion'
455
+ )
456
+ connection.execute(
457
+ _COMPLETE_FEED_RUN_SQL,
458
+ (
459
+ RunStatus.SUCCEEDED.value,
460
+ ended_at,
461
+ row_count,
462
+ to_version,
463
+ run_id,
464
+ ),
465
+ )
466
+ case RunMode.SNAPSHOT | RunMode.WATERMARK:
467
+ if to_version is not None:
468
+ raise ValueError('to_version is only valid for feed runs')
469
+ connection.execute(
470
+ _COMPLETE_NONFEED_RUN_SQL,
471
+ (RunStatus.SUCCEEDED.value, ended_at, row_count, run_id),
472
+ )
473
+ logger.debug('completed run: run_id=%s row_count=%s', run_id, row_count)
474
+
475
+ def fail_run(self, run_id: int, *, error_detail: str) -> None:
476
+ """
477
+ Close a run ``failed`` with an error detail.
478
+
479
+ Arm-agnostic: failing a run touches no range column, so no arm read is
480
+ needed — an UPDATE that matches no row is the unknown-``run_id`` signal.
481
+
482
+ Args:
483
+ run_id: The run to close, from one of the ``start_*_run`` methods.
484
+ error_detail: Human-readable failure context recorded on the row.
485
+
486
+ Raises:
487
+ ValueError: ``run_id`` is unknown (the UPDATE matched no row) — a
488
+ caller bug, kept stdlib.
489
+
490
+ Side Effects:
491
+ Opens a connection, updates the row, and commits.
492
+ """
493
+ ended_at: str = to_iso8601(self._clock.now_utc())
494
+ with self._database.transaction() as connection:
495
+ affected: int = connection.execute(
496
+ _FAIL_RUN_SQL,
497
+ (RunStatus.FAILED.value, ended_at, error_detail, run_id),
498
+ ).rowcount
499
+ if affected == 0:
500
+ raise ValueError(f'no run with run_id {run_id}')
501
+ logger.debug('failed run: run_id=%s', run_id)
502
+
503
+ def coverage_frontier(self, provider: Provider, endpoint: str) -> datetime | None:
504
+ """
505
+ Return the high-water mark of completed watermark coverage, or ``None``.
506
+
507
+ ``max(window_end)`` over this endpoint's ``succeeded`` runs carrying a
508
+ window — the implementation of DESIGN §4/§5 resume arm (2). A backfill
509
+ chunk that completed empty is still ``succeeded``, so its window counts and
510
+ empty history is never re-scanned. The lexical ``max`` over the TEXT column
511
+ is the chronological one because ``to_iso8601`` emits a fixed-width,
512
+ zero-padded, ``Z``-suffixed form. Watermark-only by design: feed and
513
+ snapshot endpoints never reach this arm (a feed endpoint holds a committed
514
+ cursor; a snapshot has no resume).
515
+
516
+ Args:
517
+ provider: The provider whose coverage to read.
518
+ endpoint: The endpoint whose coverage to read.
519
+
520
+ Returns:
521
+ The latest covered ``window_end`` as a UTC datetime, or ``None`` when no
522
+ succeeded watermark run exists for this (provider, endpoint).
523
+
524
+ Raises:
525
+ ConfigurationError: A stored ``window_end`` is not parseable ISO-8601
526
+ UTC — state-store corruption, the same stance as the cursor store.
527
+ RuntimeError: The aggregated ``window_end`` came back non-text,
528
+ violating the STRICT ``TEXT`` schema contract.
529
+
530
+ Side Effects:
531
+ Opens a connection and reads one aggregate row.
532
+ """
533
+ return _max_over_succeeded_runs(
534
+ self._database,
535
+ _COVERAGE_FRONTIER_SQL,
536
+ provider,
537
+ endpoint,
538
+ column='window_end',
539
+ )
540
+
541
+ def last_success_at(self, provider: Provider, endpoint: str) -> datetime | None:
542
+ """
543
+ Return when this endpoint last completed successfully, or ``None``.
544
+
545
+ ``max(ended_at)`` over this endpoint's ``succeeded`` runs -- the wall-clock
546
+ completion time of its most recent success, across any sync mode (a snapshot
547
+ feeder carries ``ended_at`` but no window). The roster's staleness bound reads
548
+ this to decide whether a feeder re-list is due; it is not a resume arm, so
549
+ unlike ``coverage_frontier`` it does not filter on ``window_end``. The lexical
550
+ ``max`` over the TEXT column is the chronological one because ``to_iso8601``
551
+ emits a fixed-width, zero-padded, ``Z``-suffixed form.
552
+
553
+ Args:
554
+ provider: The provider whose last success to read.
555
+ endpoint: The endpoint whose last success to read.
556
+
557
+ Returns:
558
+ The latest ``ended_at`` of a succeeded run as a UTC datetime, or ``None``
559
+ when no succeeded run exists for this (provider, endpoint).
560
+
561
+ Raises:
562
+ ConfigurationError: A stored ``ended_at`` is not parseable ISO-8601 UTC --
563
+ state-store corruption, the same stance as ``coverage_frontier``.
564
+ RuntimeError: The aggregated ``ended_at`` came back non-text, violating the
565
+ STRICT ``TEXT`` schema contract.
566
+
567
+ Side Effects:
568
+ Opens a connection and reads one aggregate row.
569
+ """
570
+ return _max_over_succeeded_runs(
571
+ self._database, _LAST_SUCCESS_SQL, provider, endpoint, column='ended_at'
572
+ )