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,430 @@
1
+ # src/fleetpull/state/migrations.py
2
+ """Schema migration runner for the operational state database.
3
+
4
+ Brings a state database's schema up to the current head version. The schema is
5
+ versioned with SQLite's ``user_version`` header field: a fresh database carries
6
+ ``user_version = 0``, and each migration step raises it by one as it applies its
7
+ DDL. :func:`migrate_to_head` reads where a database is and applies every pending
8
+ step in order, so a database created by an earlier fleetpull (with fewer tables)
9
+ upgrades in place to the current schema — the path a developer's own state file
10
+ takes as new tables land across prompts.
11
+
12
+ Migrations run once at startup, single-threaded, AFTER
13
+ :meth:`StateDatabase.initialize` (which establishes WAL, the ``application_id``,
14
+ and integrity but deliberately leaves ``user_version`` alone). Each step is
15
+ atomic: its DDL and the ``user_version`` bump commit together or not at all, so a
16
+ crash mid-migration leaves the database at its prior version with the step
17
+ un-applied, and the next run retries cleanly. A database whose version is *newer*
18
+ than this code's head is refused — the code is older than the file and cannot
19
+ know the schema.
20
+
21
+ This module owns schema evolution only; reading and writing the rows of any table
22
+ (the ``cursors``, ``runs``, ``work_units``, and ``rosters`` tables created here)
23
+ belongs to the store layers built on top. Today the head is version 3: v1 is the
24
+ ``cursors``, ``runs``, and ``work_units`` tables; v2 adds the ``rosters`` table;
25
+ v3 adds ``work_units.observed_max`` (the prefix-advance watermark rule's datum).
26
+ """
27
+
28
+ import logging
29
+ import sqlite3
30
+ from collections.abc import Callable, Iterator
31
+ from contextlib import contextmanager
32
+ from dataclasses import dataclass
33
+ from typing import Final
34
+
35
+ from fleetpull.exceptions import ConfigurationError
36
+ from fleetpull.state.database import SqliteScalar, StateDatabase, fetch_scalar
37
+
38
+ __all__: list[str] = ['migrate_to_head']
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # The cursors table (schema v1): one row per (provider, endpoint), holding the
43
+ # tagged-union resume cursor (DESIGN §4/§5). ``kind`` discriminates the union
44
+ # member and is CHECK-constrained to the two valid values, so an unknown
45
+ # discriminator is refused at the boundary; ``value`` is the member's serialized
46
+ # form (the store layer owns that serialization); ``updated_at`` is the ISO
47
+ # instant the row was last written. STRICT enforces the declared column types,
48
+ # and the (provider, endpoint) primary key makes a cursor write a single-row
49
+ # upsert.
50
+ _CURSORS_TABLE_DDL: Final[str] = """
51
+ CREATE TABLE cursors (
52
+ provider TEXT NOT NULL,
53
+ endpoint TEXT NOT NULL,
54
+ kind TEXT NOT NULL CHECK (
55
+ kind IN ('date_watermark', 'feed_token')
56
+ ),
57
+ value TEXT NOT NULL,
58
+ updated_at TEXT NOT NULL,
59
+ PRIMARY KEY (provider, endpoint)
60
+ ) STRICT
61
+ """
62
+
63
+ # The runs table (joins schema v1): one row per fetch of one (provider, endpoint)
64
+ # in one of three sync modes — a snapshot (no range), a watermark window, or a feed
65
+ # version range — the operational record the run ledger reads and writes
66
+ # (DESIGN §5). A ``mode`` column records which, so the row is self-describing.
67
+ # ``run_id`` is the rowid alias, auto-assigned by the INSERT. The range columns,
68
+ # ``row_count``, ``ended_at``, and ``error_detail`` are nullable because a two-phase
69
+ # run fills them across its lifecycle: the mode's range shape at start, ``row_count``
70
+ # (and a feed run's ``to_version``) at completion, ``error_detail`` only on failure.
71
+ # ``row_count`` means "records the run produced"; the sink those records landed in
72
+ # (a roster for a coordinator harvest, parquet for a runner-driven fetch) follows
73
+ # from the run's ``mode`` and origin, which the row already carries.
74
+ # Three table CHECKs are the DB-layer backstop behind the RunLedger API guards — a
75
+ # mode-keyed range shape (snapshot carries no range; watermark carries a window;
76
+ # feed carries ``from_version``; ``to_version`` is admissible only on a feed run, so
77
+ # a snapshot or watermark row carrying one is impossible), a non-negative
78
+ # ``row_count``, and a window ordered ``window_start < window_end``. The ``status``
79
+ # and ``mode`` columns carry their own value CHECKs. The window bounds compare
80
+ # lexically because ``to_iso8601`` emits a fixed-width, zero-padded, Z-suffixed
81
+ # form, making the TEXT comparison the chronological one — the same property
82
+ # ``coverage_frontier``'s ``max()`` relies on; do not loosen the codec format
83
+ # without revisiting both. STRICT enforces the declared column types.
84
+ _RUNS_TABLE_DDL: Final[str] = """
85
+ CREATE TABLE runs (
86
+ run_id INTEGER PRIMARY KEY,
87
+ provider TEXT NOT NULL,
88
+ endpoint TEXT NOT NULL,
89
+ status TEXT NOT NULL CHECK (
90
+ status IN ('running', 'succeeded', 'failed')
91
+ ),
92
+ mode TEXT NOT NULL CHECK (
93
+ mode IN ('snapshot', 'watermark', 'feed')
94
+ ),
95
+ window_start TEXT,
96
+ window_end TEXT,
97
+ from_version TEXT,
98
+ to_version TEXT,
99
+ row_count INTEGER,
100
+ started_at TEXT NOT NULL,
101
+ ended_at TEXT,
102
+ error_detail TEXT,
103
+ CHECK (
104
+ (mode = 'snapshot'
105
+ AND window_start IS NULL AND window_end IS NULL
106
+ AND from_version IS NULL AND to_version IS NULL)
107
+ OR (mode = 'watermark'
108
+ AND window_start IS NOT NULL AND window_end IS NOT NULL
109
+ AND from_version IS NULL AND to_version IS NULL)
110
+ OR (mode = 'feed'
111
+ AND from_version IS NOT NULL
112
+ AND window_start IS NULL AND window_end IS NULL)
113
+ ),
114
+ CHECK (row_count IS NULL OR row_count >= 0),
115
+ CHECK (window_start IS NULL OR window_end IS NULL
116
+ OR window_start < window_end)
117
+ ) STRICT
118
+ """
119
+
120
+ # The work_units table (joins schema v1): the backfill claim queue (DESIGN §5). One
121
+ # row per unit — a date chunk (``chunk_start``/``chunk_end``) of one
122
+ # (provider, endpoint), optionally with an opaque ``partition_key`` (a vehicle id,
123
+ # a driver id, ...; NULL for an unpartitioned endpoint, which the store never
124
+ # interprets). ``unit_id`` is the rowid alias. ``status`` defaults to ``pending``
125
+ # and ``attempt_count`` to 0, so enqueue inserts only the identity + chunk columns;
126
+ # ``claimed_at``/``finished_at``/``last_error`` fill across the claim lifecycle. The
127
+ # CHECKs are the DB-layer backstop behind the WorkUnitStore guards: a closed status
128
+ # set, a non-negative ``attempt_count``, and a chunk ordered
129
+ # ``chunk_start < chunk_end`` (lexical = chronological under ``to_iso8601``'s
130
+ # fixed-width Z-form). STRICT enforces the declared types.
131
+ _WORK_UNITS_TABLE_DDL: Final[str] = """
132
+ CREATE TABLE work_units (
133
+ unit_id INTEGER PRIMARY KEY,
134
+ provider TEXT NOT NULL,
135
+ endpoint TEXT NOT NULL,
136
+ partition_key TEXT,
137
+ chunk_start TEXT NOT NULL,
138
+ chunk_end TEXT NOT NULL,
139
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (
140
+ status IN ('pending', 'claimed', 'done', 'failed')
141
+ ),
142
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
143
+ claimed_at TEXT,
144
+ finished_at TEXT,
145
+ last_error TEXT,
146
+ CHECK (chunk_start < chunk_end)
147
+ ) STRICT
148
+ """
149
+
150
+ # Three indexes back the queue (all verified in-container). The two PARTIAL UNIQUE
151
+ # indexes give idempotent enqueue (``INSERT OR IGNORE`` on the natural key): SQLite
152
+ # treats NULL as distinct in a unique index, so one index covers the
153
+ # ``partition_key IS NOT NULL`` arm and a second covers the NULL arm — deduping
154
+ # unpartitioned units that a single ``UNIQUE(...)`` would miss. The natural key is
155
+ # the full window (``chunk_start`` AND ``chunk_end``), so a same-start/different-end
156
+ # unit is distinct — overlap/plan-consistency is the caller's concern, not the
157
+ # store's. The third index is the PARTIAL claim index: ``(provider, endpoint,
158
+ # unit_id)`` filtered to claimable statuses lets ``claim_next``'s ``ORDER BY
159
+ # unit_id`` run sort-free (completed units leave the index), ~2x faster than a sort
160
+ # over the full table at 20k units.
161
+ _WORK_UNITS_INDEX_DDLS: Final[tuple[str, ...]] = (
162
+ """
163
+ CREATE UNIQUE INDEX ux_work_units_partitioned
164
+ ON work_units (provider, endpoint, partition_key, chunk_start, chunk_end)
165
+ WHERE partition_key IS NOT NULL
166
+ """,
167
+ """
168
+ CREATE UNIQUE INDEX ux_work_units_unpartitioned
169
+ ON work_units (provider, endpoint, chunk_start, chunk_end)
170
+ WHERE partition_key IS NULL
171
+ """,
172
+ """
173
+ CREATE INDEX ix_work_units_claimable
174
+ ON work_units (provider, endpoint, unit_id)
175
+ WHERE status IN ('pending', 'failed')
176
+ """,
177
+ )
178
+
179
+ # The rosters table (joins schema v2): the persisted fan-out roster members.
180
+ # One row per fan-out member (``member``) of one roster, identified by a
181
+ # ``RosterKey`` ``(provider, name)`` -- the per-vehicle id set ``vehicle_locations``
182
+ # fans out over, listed from the ``vehicles`` feeder and kept here so the fan-out
183
+ # reads the roster, never the feeder's output parquet (DESIGN §3/§5). The roster's
184
+ # source endpoint and column are not stored here; they live in its
185
+ # ``RosterDefinition``. ``absence_count`` is the consecutive-miss hysteresis counter
186
+ # the reconcile logic drives; the composite primary key makes a member a single-row
187
+ # upsert. STRICT enforces the declared types; the non-negative CHECK is the DB-layer
188
+ # backstop on the counter.
189
+ _ROSTERS_TABLE_DDL: Final[str] = """
190
+ CREATE TABLE rosters (
191
+ provider TEXT NOT NULL,
192
+ name TEXT NOT NULL,
193
+ member TEXT NOT NULL,
194
+ absence_count INTEGER NOT NULL DEFAULT 0 CHECK (absence_count >= 0),
195
+ PRIMARY KEY (provider, name, member)
196
+ ) STRICT
197
+ """
198
+
199
+
200
+ @dataclass(frozen=True, slots=True)
201
+ class _Migration:
202
+ """
203
+ One ordered schema step: the version it produces and the change that gets there.
204
+
205
+ Attributes:
206
+ version: The ``user_version`` the database carries once this step has
207
+ applied. Steps are authored in ascending, contiguous order from 1.
208
+ apply: Applies the step's schema change to an open connection, inside the
209
+ caller's transaction. Takes the connection and returns nothing.
210
+ """
211
+
212
+ version: int
213
+ apply: Callable[[sqlite3.Connection], None]
214
+
215
+
216
+ def _create_cursors_table(connection: sqlite3.Connection) -> None:
217
+ """
218
+ Create the ``cursors`` table.
219
+
220
+ Args:
221
+ connection: An open connection, inside the migration's transaction.
222
+
223
+ Side Effects:
224
+ Executes ``CREATE TABLE`` on ``connection``.
225
+ """
226
+ connection.execute(_CURSORS_TABLE_DDL)
227
+
228
+
229
+ def _create_runs_table(connection: sqlite3.Connection) -> None:
230
+ """
231
+ Create the ``runs`` table.
232
+
233
+ Args:
234
+ connection: An open connection, inside the migration's transaction.
235
+
236
+ Side Effects:
237
+ Executes ``CREATE TABLE`` on ``connection``.
238
+ """
239
+ connection.execute(_RUNS_TABLE_DDL)
240
+
241
+
242
+ def _create_work_units_table(connection: sqlite3.Connection) -> None:
243
+ """
244
+ Create the ``work_units`` table and its three indexes.
245
+
246
+ Args:
247
+ connection: An open connection, inside the migration's transaction.
248
+
249
+ Side Effects:
250
+ Executes one ``CREATE TABLE`` and three ``CREATE INDEX`` statements on
251
+ ``connection``.
252
+ """
253
+ connection.execute(_WORK_UNITS_TABLE_DDL)
254
+ for index_ddl in _WORK_UNITS_INDEX_DDLS:
255
+ connection.execute(index_ddl)
256
+
257
+
258
+ def _create_rosters_table(connection: sqlite3.Connection) -> None:
259
+ """
260
+ Create the ``rosters`` table (schema v2).
261
+
262
+ Args:
263
+ connection: An open connection, inside the migration's transaction.
264
+
265
+ Side Effects:
266
+ Executes ``CREATE TABLE`` on ``connection``.
267
+ """
268
+ connection.execute(_ROSTERS_TABLE_DDL)
269
+
270
+
271
+ def _add_observed_max_column(connection: sqlite3.Connection) -> None:
272
+ """
273
+ Apply schema v3: add ``observed_max`` to ``work_units``.
274
+
275
+ A done unit's folded in-window maximum event time (``to_iso8601`` form),
276
+ NULL for an empty unit or one completed before v3. The prefix-advance
277
+ watermark rule (DESIGN section 5, 2026-07-20) reads it: the cursor may
278
+ advance to the maximum observation across the contiguous done-prefix of
279
+ the plan, so out-of-order parallel unit completions never overstate the
280
+ watermark.
281
+
282
+ Args:
283
+ connection: An open connection, inside the migration's transaction.
284
+
285
+ Side Effects:
286
+ Executes one ``ALTER TABLE`` on ``connection``.
287
+ """
288
+ connection.execute('ALTER TABLE work_units ADD COLUMN observed_max TEXT')
289
+
290
+
291
+ def _create_initial_schema(connection: sqlite3.Connection) -> None:
292
+ """
293
+ Apply schema v1: create the initial tables — ``cursors``, ``runs``, ``work_units``.
294
+
295
+ All three tables form the v1 head. No state database has applied an earlier
296
+ schema (none exists anywhere), so each joins v1 here rather than arriving as a
297
+ separate version bump.
298
+
299
+ Args:
300
+ connection: An open connection, inside the migration's transaction.
301
+
302
+ Side Effects:
303
+ Executes each table's ``CREATE TABLE`` (and the work-units indexes) on
304
+ ``connection``.
305
+ """
306
+ _create_cursors_table(connection)
307
+ _create_runs_table(connection)
308
+ _create_work_units_table(connection)
309
+
310
+
311
+ # Ordered by ascending version; the last entry's version is the head the schema
312
+ # is migrated up to. A future schema change appends a new step.
313
+ _MIGRATIONS: Final[tuple[_Migration, ...]] = (
314
+ _Migration(version=1, apply=_create_initial_schema),
315
+ _Migration(version=2, apply=_create_rosters_table),
316
+ _Migration(version=3, apply=_add_observed_max_column),
317
+ )
318
+
319
+
320
+ def migrate_to_head(state_database: StateDatabase) -> None:
321
+ """
322
+ Bring the state database's schema up to the current head version.
323
+
324
+ Reads the database's ``user_version`` and applies every migration step above
325
+ it, in order, each in its own transaction. Idempotent: a database already at
326
+ head has no pending steps and is left untouched. Run once at startup,
327
+ single-threaded, after :meth:`StateDatabase.initialize`.
328
+
329
+ Args:
330
+ state_database: The initialized state database to migrate;
331
+ :meth:`StateDatabase.connect` supplies the connection.
332
+
333
+ Raises:
334
+ ConfigurationError: The database's ``user_version`` is newer than this
335
+ code's head — the code is older than the database and cannot know its
336
+ schema.
337
+ sqlite3.DatabaseError: A migration's DDL failed; the offending step's
338
+ transaction is rolled back, leaving the database at its prior version.
339
+
340
+ Side Effects:
341
+ Opens a connection, applies pending DDL, and advances ``user_version``.
342
+ """
343
+ head_version: int = _MIGRATIONS[-1].version
344
+ with state_database.connect() as connection:
345
+ # Take manual transaction control: under the default isolation an
346
+ # explicit BEGIN raises once an implicit transaction is open, so each
347
+ # step could not own a clean BEGIN/COMMIT. Autocommit mode lets the
348
+ # step's DDL and its user_version bump commit atomically.
349
+ connection.isolation_level = None
350
+ current_version: int = _read_user_version(connection)
351
+ if current_version > head_version:
352
+ raise ConfigurationError(
353
+ 'state database schema is newer than this version of fleetpull',
354
+ detail=(
355
+ f'database at {state_database.database_path} is at schema '
356
+ f'version {current_version}, newer than this build '
357
+ f'understands (head {head_version}); upgrade fleetpull to '
358
+ f'operate on it'
359
+ ),
360
+ )
361
+ for migration in _MIGRATIONS:
362
+ if migration.version <= current_version:
363
+ continue
364
+ _apply_migration(connection, migration)
365
+ logger.info('Applied state schema migration: version=%d', migration.version)
366
+
367
+
368
+ def _apply_migration(connection: sqlite3.Connection, migration: _Migration) -> None:
369
+ """
370
+ Apply one migration step atomically: its DDL and the version bump together.
371
+
372
+ Args:
373
+ connection: An open connection in manual-transaction (autocommit) mode.
374
+ migration: The step to apply.
375
+
376
+ Side Effects:
377
+ Runs the step's change and sets ``user_version`` within one transaction.
378
+ """
379
+ with _transaction(connection):
380
+ migration.apply(connection)
381
+ connection.execute(f'PRAGMA user_version = {migration.version}')
382
+
383
+
384
+ @contextmanager
385
+ def _transaction(connection: sqlite3.Connection) -> Iterator[None]:
386
+ """
387
+ Run the enclosed statements in one transaction; commit on success, else roll back.
388
+
389
+ Requires the connection in manual-transaction (autocommit) mode. The commit
390
+ is recorded before the ``finally``, so a failure anywhere in the block rolls
391
+ back rather than leaving a half-applied step.
392
+
393
+ Args:
394
+ connection: An open connection in autocommit mode.
395
+
396
+ Side Effects:
397
+ Issues ``BEGIN`` and exactly one of ``COMMIT`` / ``ROLLBACK``.
398
+ """
399
+ connection.execute('BEGIN')
400
+ committed: bool = False
401
+ try:
402
+ yield
403
+ connection.execute('COMMIT')
404
+ committed = True
405
+ finally:
406
+ if not committed:
407
+ connection.execute('ROLLBACK')
408
+
409
+
410
+ def _read_user_version(connection: sqlite3.Connection) -> int:
411
+ """
412
+ Read the database's ``user_version`` header field as an ``int``.
413
+
414
+ Args:
415
+ connection: An open connection.
416
+
417
+ Returns:
418
+ The current ``user_version`` (0 on a fresh database).
419
+
420
+ Raises:
421
+ RuntimeError: The PRAGMA returned a non-integer — a SQLite contract
422
+ violation, surfaced loudly.
423
+
424
+ Side Effects:
425
+ Reads ``PRAGMA user_version``.
426
+ """
427
+ version: SqliteScalar = fetch_scalar(connection, 'PRAGMA user_version')
428
+ if not isinstance(version, int):
429
+ raise RuntimeError(f'expected an integer user_version, got {version!r}')
430
+ return version
@@ -0,0 +1,127 @@
1
+ # src/fleetpull/state/reconcile.py
2
+ """The pure roster-reconciliation half: the delta, the reconcile, the staleness.
3
+
4
+ The listing-to-delta logic behind a roster refresh, split from the store it
5
+ feeds (``state/rosters.py``) so the pure set arithmetic and the SQLite
6
+ read/write orchestration live one concern per file. Refresh is
7
+ reconcile-then-apply: list the feeder, ``reconcile`` the listing against the
8
+ current roster into a three-set delta (reset, increment, evict), and
9
+ ``RosterStore.apply`` it in one transaction. The absence counter is hysteresis
10
+ -- a key absent from one listing is not dropped on the first miss, only after
11
+ ``eviction_threshold`` consecutive misses -- and append-only is the degenerate
12
+ case (``eviction_threshold`` ``None`` evicts nothing). With permanent,
13
+ absent-means-empty keys (vehicle ids) the counter is an efficiency mechanism,
14
+ not a correctness one: it stops the fan-out paying for empty fetches over
15
+ long-retired vehicles, so the threshold is generous. ``is_roster_stale`` is
16
+ the refresh-due decision the coordinator takes against the feeder's
17
+ ledger-recorded last success.
18
+ """
19
+
20
+ from collections.abc import Iterable, Mapping
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timedelta
23
+
24
+ __all__: list[str] = [
25
+ 'RosterDelta',
26
+ 'is_roster_stale',
27
+ 'reconcile',
28
+ ]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class RosterDelta:
33
+ """A reconciliation result: the three roster mutations a refresh applies.
34
+
35
+ The disjoint sets one refresh produces -- the output of ``reconcile`` and the input
36
+ to ``RosterStore.apply``. A member present at count zero and still listed appears in
37
+ none of the three: no write is needed.
38
+
39
+ Attributes:
40
+ to_zero: Members to upsert at absence-count zero -- new keys (insert) and
41
+ reappeared keys that had a nonzero count (reset).
42
+ to_increment: Roster members absent from the listing whose incremented count
43
+ stays at or below the eviction threshold -- a tolerated miss.
44
+ to_evict: Roster members absent from the listing whose incremented count would
45
+ exceed the threshold -- dropped from the roster.
46
+ """
47
+
48
+ to_zero: frozenset[str]
49
+ to_increment: frozenset[str]
50
+ to_evict: frozenset[str]
51
+
52
+
53
+ def reconcile(
54
+ current: Mapping[str, int],
55
+ listed: Iterable[str],
56
+ eviction_threshold: int | None,
57
+ ) -> RosterDelta:
58
+ """Reconcile a fresh listing against the current roster into a delta.
59
+
60
+ Pure set logic over the current roster (``{member: absence_count}``) and the
61
+ freshly-listed keys. Listed keys reset to zero (new ones insert at zero); keys
62
+ absent from the listing increment, and tip to eviction once the incremented count
63
+ would exceed ``eviction_threshold``. ``None`` threshold is append-only -- nothing
64
+ ever evicts. The threshold lives here so ``RosterStore.apply`` stays a dumb writer.
65
+
66
+ Args:
67
+ current: The roster as ``{member: absence_count}`` (from ``read_counts``).
68
+ listed: The keys the feeder just produced (``extract_roster_members``);
69
+ duplicates collapse.
70
+ eviction_threshold: Consecutive-miss count past which a member is evicted, or
71
+ ``None`` for append-only (never evict).
72
+
73
+ Returns:
74
+ The ``RosterDelta`` of disjoint reset / increment / evict sets.
75
+
76
+ Side Effects:
77
+ None -- pure function.
78
+ """
79
+ listed_set = set(listed)
80
+ current_set = set(current)
81
+ new = listed_set - current_set
82
+ reappeared = {member for member in listed_set & current_set if current[member]}
83
+ absent = current_set - listed_set
84
+ to_increment: set[str]
85
+ to_evict: set[str]
86
+ if eviction_threshold is None:
87
+ to_increment = absent
88
+ to_evict = set()
89
+ else:
90
+ to_increment = {
91
+ member for member in absent if current[member] + 1 <= eviction_threshold
92
+ }
93
+ to_evict = {
94
+ member for member in absent if current[member] + 1 > eviction_threshold
95
+ }
96
+ return RosterDelta(
97
+ to_zero=frozenset(new | reappeared),
98
+ to_increment=frozenset(to_increment),
99
+ to_evict=frozenset(to_evict),
100
+ )
101
+
102
+
103
+ def is_roster_stale(
104
+ last_success: datetime | None, now: datetime, max_age: timedelta
105
+ ) -> bool:
106
+ """Whether the roster is older than its staleness bound and a refresh is due.
107
+
108
+ Pure decision from the feeder's last successful run time
109
+ (``RunLedger.last_success_at``) against ``now`` and the allowed ``max_age``.
110
+ ``None`` -- the feeder has never succeeded, so no roster has been built -- is stale.
111
+ The caller treats this as best-effort: a stale verdict triggers a refresh attempt,
112
+ but a failed refresh falls back to the existing roster rather than blocking the
113
+ fan-out (the cold-start-empty case is the orchestrator's loud failure, not this
114
+ function's).
115
+
116
+ Args:
117
+ last_success: When the feeder last completed successfully, or ``None``.
118
+ now: The current instant (the caller's ``Clock``).
119
+ max_age: The maximum tolerated roster age before a refresh is due.
120
+
121
+ Returns:
122
+ ``True`` when a refresh is due (no prior success, or older than ``max_age``).
123
+
124
+ Side Effects:
125
+ None -- pure function.
126
+ """
127
+ return last_success is None or now - last_success > max_age