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,463 @@
1
+ # src/fleetpull/state/database.py
2
+ """SQLite connection lifecycle and integrity verification for the operational state store.
3
+
4
+ The connection substrate beneath the `state/` package (DESIGN §5). One SQLite
5
+ database lives at the resolved path passed to :class:`StateDatabase` at
6
+ construction; this module does not derive that path (runtime config resolves it).
7
+
8
+ Layout is a functional core under a thin shell. The public module-level
9
+ functions are the stateless primitives the store layers above import and
10
+ reuse: :func:`fetch_scalar` (the migration runner's version read),
11
+ :func:`expect_text` / :func:`expect_int` (the STRICT-schema narrowings every
12
+ store applies to a read column), and :func:`parse_stored_instant` (the
13
+ stored-ISO-8601 parse whose failure is state-store corruption). The
14
+ verification primitives (:func:`_apply_connection_pragmas`,
15
+ :func:`_stamp_or_verify_application_id`, :func:`_enable_wal`,
16
+ :func:`_verify_quick_check`) are module-private — only :class:`StateDatabase`
17
+ sequences them. :class:`StateDatabase` is the shell
18
+ that owns the path, creates the file, sequences the verification primitives at
19
+ startup, and hands out per-connection-configured connections — plain reads via
20
+ :meth:`StateDatabase.connect`, writes via :meth:`StateDatabase.transaction`
21
+ (the commit-on-clean-exit wrapper every store's write runs in).
22
+
23
+ The module owns no tables. Schema DDL, the schema-version gate
24
+ (``user_version``), and the watermark/ledger/work-unit representations all
25
+ belong to the layers above (the migration runner keeps its own BEGIN-based
26
+ transaction). The module imports nothing about parquet (DESIGN §5/§11: state
27
+ knows nothing about parquet).
28
+
29
+ Failure stances:
30
+ - A path holding a different application's SQLite file (foreign
31
+ ``application_id``), a corrupt database, or a filesystem that cannot
32
+ support WAL (a non-local filesystem) all raise ``ConfigurationError`` — the
33
+ operator must fix the local state store or its location, then rerun. A
34
+ removed-because-corrupt store rebuilds on the next run via refetch
35
+ (delete-by-window merge makes that idempotent, §5).
36
+ - A path holding a file that is not a SQLite database at all surfaces the
37
+ stdlib ``sqlite3.DatabaseError`` unchanged.
38
+ - Calling ``connect`` before ``initialize`` is a wiring bug and raises stdlib
39
+ ``RuntimeError`` — a caller bug, kept out of the operational hierarchy (§8).
40
+ """
41
+
42
+ import logging
43
+ import sqlite3
44
+ from collections.abc import Iterator
45
+ from contextlib import contextmanager
46
+ from datetime import datetime
47
+ from pathlib import Path
48
+ from typing import Final
49
+
50
+ from fleetpull.exceptions import ConfigurationError
51
+ from fleetpull.timing import from_iso8601
52
+ from fleetpull.vocabulary import Provider
53
+
54
+ __all__: list[str] = [
55
+ 'SqliteScalar',
56
+ 'StateDatabase',
57
+ 'expect_int',
58
+ 'expect_text',
59
+ 'fetch_scalar',
60
+ 'parse_stored_instant',
61
+ ]
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ # A single column value as SQLite returns it through the DBAPI: the storage
66
+ # classes map to exactly these Python types. Used to type the raw scalar read
67
+ # below precisely, rather than falling back to ``object``.
68
+ type SqliteScalar = int | float | str | bytes | None
69
+
70
+ # Stamped into the SQLite header's application_id field at creation and verified
71
+ # on every initialization: a fixed nonzero magic marking a file as fleetpull's
72
+ # own state store, so a foreign SQLite file sharing the path is refused rather
73
+ # than written to. Value is the ASCII bytes b'flpl' (0x666C706C), an arbitrary
74
+ # but stable 32-bit identifier.
75
+ _APPLICATION_ID: Final[int] = 0x666C706C
76
+
77
+ # Short per-connection lock-wait before SQLite raises SQLITE_BUSY (DESIGN §5:
78
+ # short busy_timeout). A few seconds absorbs brief single-writer contention
79
+ # without masking a genuine deadlock.
80
+ _DEFAULT_BUSY_TIMEOUT_MS: Final[int] = 5000
81
+
82
+
83
+ def fetch_scalar(connection: sqlite3.Connection, statement: str) -> SqliteScalar:
84
+ """
85
+ Execute a single-row, single-column statement and return its raw value.
86
+
87
+ The shared read primitive beneath the verification helpers and the
88
+ migration runner's version read: it centralizes
89
+ the fetch and the empty-result guard so callers narrow a known type rather
90
+ than re-handling the cursor.
91
+
92
+ Args:
93
+ connection: The connection to execute against.
94
+ statement: A statement expected to yield one row of one column.
95
+
96
+ Returns:
97
+ The single column value as SQLite returns it.
98
+
99
+ Raises:
100
+ RuntimeError: The statement returned no row — a contract violation for
101
+ the pragmas read here, surfaced loudly rather than guessed around.
102
+
103
+ Side Effects:
104
+ Executes ``statement`` on ``connection``.
105
+ """
106
+ row: tuple[SqliteScalar, ...] | None = connection.execute(statement).fetchone()
107
+ if row is None:
108
+ raise RuntimeError(f'expected one row from {statement!r}, got none')
109
+ return row[0]
110
+
111
+
112
+ def expect_text(value: SqliteScalar, column: str) -> str:
113
+ """
114
+ Narrow a read scalar to the TEXT its STRICT schema promises.
115
+
116
+ The shared narrowing every store applies to a column it reads: a non-text
117
+ value under a ``TEXT`` STRICT column is a SQLite contract violation,
118
+ surfaced loudly rather than coerced.
119
+
120
+ Args:
121
+ value: The raw scalar as SQLite returned it.
122
+ column: The column's name (e.g. ``'cursors.kind'``), for the error.
123
+
124
+ Returns:
125
+ The value as text.
126
+
127
+ Raises:
128
+ RuntimeError: ``value`` is not text.
129
+ """
130
+ if not isinstance(value, str):
131
+ raise RuntimeError(f'{column} was not text: {value!r}')
132
+ return value
133
+
134
+
135
+ def expect_int(value: SqliteScalar, column: str) -> int:
136
+ """
137
+ Narrow a read scalar to the INTEGER its STRICT schema promises.
138
+
139
+ The integer twin of :func:`expect_text`.
140
+
141
+ Args:
142
+ value: The raw scalar as SQLite returned it.
143
+ column: The column's name (e.g. ``'work_units.unit_id'``), for the
144
+ error.
145
+
146
+ Returns:
147
+ The value as an integer.
148
+
149
+ Raises:
150
+ RuntimeError: ``value`` is not an integer.
151
+ """
152
+ if not isinstance(value, int):
153
+ raise RuntimeError(f'{column} was not an integer: {value!r}')
154
+ return value
155
+
156
+
157
+ def parse_stored_instant(
158
+ text: str, *, provider: Provider, endpoint: str, column: str
159
+ ) -> datetime:
160
+ """
161
+ Parse a stored ISO-8601 UTC instant; failure is state-store corruption.
162
+
163
+ The shared read-side parse behind every store's persisted timestamp: the
164
+ stores only ever write ``to_iso8601`` text, so a stored value that does
165
+ not parse back is state-store corruption and raises ``ConfigurationError``
166
+ (the uniform §5 stance).
167
+
168
+ Args:
169
+ text: The stored text to parse.
170
+ provider: The provider whose row this is, for the error context.
171
+ endpoint: The endpoint whose row this is, for the error context.
172
+ column: What the value is (e.g. ``'run window_end'``), naming the
173
+ corrupt datum in the raised error.
174
+
175
+ Returns:
176
+ The parsed timezone-aware UTC datetime.
177
+
178
+ Raises:
179
+ ConfigurationError: ``text`` is not parseable ISO-8601 UTC.
180
+ """
181
+ try:
182
+ return from_iso8601(text)
183
+ except ValueError as error:
184
+ raise ConfigurationError(
185
+ f'state database holds an unparseable {column}',
186
+ provider=provider.value,
187
+ endpoint=endpoint,
188
+ detail=f'{column} {text!r} is not ISO-8601 UTC',
189
+ ) from error
190
+
191
+
192
+ def _apply_connection_pragmas(
193
+ connection: sqlite3.Connection, busy_timeout_ms: int
194
+ ) -> None:
195
+ """
196
+ Apply the connection-scoped pragmas every state connection needs.
197
+
198
+ ``busy_timeout`` and ``foreign_keys`` are per-connection settings (they do
199
+ not persist in the file header), so they are set on every connection rather
200
+ than once at initialization.
201
+
202
+ Args:
203
+ connection: The connection to configure.
204
+ busy_timeout_ms: Lock-wait in milliseconds before SQLite raises
205
+ ``SQLITE_BUSY``.
206
+
207
+ Side Effects:
208
+ Issues ``PRAGMA`` statements on ``connection``.
209
+ """
210
+ connection.execute(f'PRAGMA busy_timeout = {busy_timeout_ms}')
211
+ connection.execute('PRAGMA foreign_keys = ON')
212
+
213
+
214
+ def _stamp_or_verify_application_id(
215
+ connection: sqlite3.Connection, database_path: Path
216
+ ) -> None:
217
+ """
218
+ Stamp a fresh database's ``application_id`` or verify an existing one.
219
+
220
+ A brand-new SQLite file carries ``application_id = 0``; that case is stamped
221
+ with the fleetpull magic. A file already carrying the magic is accepted. Any
222
+ other nonzero value means the path holds a different application's SQLite
223
+ database, refused rather than written to. Callers read this before any other
224
+ write, so a foreign file is never mutated.
225
+
226
+ Args:
227
+ connection: An open connection to the database.
228
+ database_path: The database's path, used only to identify the file in a
229
+ raised ``ConfigurationError``.
230
+
231
+ Raises:
232
+ ConfigurationError: The database carries a foreign ``application_id``.
233
+
234
+ Side Effects:
235
+ Sets ``application_id`` in the file header on a fresh database.
236
+ """
237
+ current_id: SqliteScalar = fetch_scalar(connection, 'PRAGMA application_id')
238
+ if not isinstance(current_id, int):
239
+ raise RuntimeError(f'expected an integer application_id, got {current_id!r}')
240
+ if current_id == 0:
241
+ connection.execute(f'PRAGMA application_id = {_APPLICATION_ID}')
242
+ return
243
+ if current_id != _APPLICATION_ID:
244
+ raise ConfigurationError(
245
+ 'state database belongs to another application',
246
+ detail=(
247
+ f'{database_path} carries application_id {current_id:#010x}, '
248
+ f'not the fleetpull application_id {_APPLICATION_ID:#010x}; '
249
+ f'point the state database path at a fleetpull-owned location'
250
+ ),
251
+ )
252
+
253
+
254
+ def _enable_wal(connection: sqlite3.Connection, database_path: Path) -> None:
255
+ """
256
+ Switch the database to WAL journaling and confirm it took.
257
+
258
+ WAL is a database-level mode persisted in the file header, so callers run
259
+ this once at initialization. The active mode is read back: SQLite silently
260
+ falls back to the prior journal mode on a filesystem that cannot support WAL
261
+ (a network filesystem), so a result other than ``'wal'`` is the loud signal
262
+ that the database is not on local disk, which DESIGN §5 requires.
263
+
264
+ Args:
265
+ connection: An open connection to the database.
266
+ database_path: The database's path, used only to identify the file in a
267
+ raised ``ConfigurationError``.
268
+
269
+ Raises:
270
+ ConfigurationError: WAL did not take — the database is not on a local
271
+ filesystem.
272
+
273
+ Side Effects:
274
+ May convert the database's journal mode (persists in the header).
275
+ """
276
+ active_mode: SqliteScalar = fetch_scalar(connection, 'PRAGMA journal_mode = WAL')
277
+ if not isinstance(active_mode, str):
278
+ raise RuntimeError(f'expected a text journal_mode, got {active_mode!r}')
279
+ if active_mode.lower() != 'wal':
280
+ raise ConfigurationError(
281
+ 'state database is not on a WAL-capable filesystem',
282
+ detail=(
283
+ f'PRAGMA journal_mode=WAL returned {active_mode!r} for '
284
+ f'{database_path}; SQLite operational state requires local disk '
285
+ f'(DESIGN §5)'
286
+ ),
287
+ )
288
+
289
+
290
+ def _verify_quick_check(connection: sqlite3.Connection, database_path: Path) -> None:
291
+ """
292
+ Run SQLite's integrity check and refuse a corrupt database.
293
+
294
+ ``PRAGMA quick_check`` returns the single value ``'ok'`` on a healthy
295
+ database; anything else is corruption. ``quick_check`` is chosen over
296
+ ``integrity_check`` because it skips the expensive cross-index consistency
297
+ pass while still detecting structural damage — adequate for a startup gate.
298
+
299
+ Args:
300
+ connection: An open connection to the database.
301
+ database_path: The database's path, used only to identify the file in a
302
+ raised ``ConfigurationError``.
303
+
304
+ Raises:
305
+ ConfigurationError: The integrity check did not return ``'ok'``.
306
+
307
+ Side Effects:
308
+ None beyond reading the database.
309
+ """
310
+ result: SqliteScalar = fetch_scalar(connection, 'PRAGMA quick_check')
311
+ if not isinstance(result, str):
312
+ raise RuntimeError(f'expected a text quick_check result, got {result!r}')
313
+ if result.lower() != 'ok':
314
+ raise ConfigurationError(
315
+ 'state database failed its integrity check',
316
+ detail=(
317
+ f'PRAGMA quick_check on {database_path} returned {result!r}; the '
318
+ f'operational state store is corrupt and must be restored or '
319
+ f'removed (a removed store rebuilds on the next run via refetch)'
320
+ ),
321
+ )
322
+
323
+
324
+ class StateDatabase:
325
+ """
326
+ Owns one operational state database's creation and verification lifecycle.
327
+
328
+ The database lives at the resolved path passed in at construction (DESIGN
329
+ §5). This class is the thin lifecycle shell over the module's database
330
+ primitives: :meth:`initialize` creates the file, stamps and verifies it, and
331
+ converts it to WAL; :meth:`connect` hands out per-connection-configured
332
+ connections and :meth:`transaction` wraps one in the stores' shared
333
+ commit-on-clean-exit policy. It holds only the path and the busy-timeout —
334
+ the mechanics are
335
+ the module-level functions above, and the schema and path
336
+ resolution belong to other layers.
337
+
338
+ Threading: SQLite connections are not shared across threads, so each worker
339
+ thread opens its own connection via :meth:`connect`. Verification and WAL
340
+ conversion are one-time startup work (:meth:`initialize`), run once
341
+ single-threaded before any worker connects.
342
+
343
+ Args:
344
+ database_path: Full, already-resolved path to the SQLite database file.
345
+ Its parent directory is created on :meth:`initialize` if absent; the
346
+ path itself is not derived from a dataset root here.
347
+ busy_timeout_ms: Per-connection lock-wait in milliseconds before SQLite
348
+ raises ``SQLITE_BUSY``. Defaults to a few seconds.
349
+ """
350
+
351
+ def __init__(
352
+ self,
353
+ database_path: Path,
354
+ *,
355
+ busy_timeout_ms: int = _DEFAULT_BUSY_TIMEOUT_MS,
356
+ ) -> None:
357
+ self._database_path: Path = database_path
358
+ self._busy_timeout_ms: int = busy_timeout_ms
359
+
360
+ @property
361
+ def database_path(self) -> Path:
362
+ """The resolved path to the SQLite database file."""
363
+ return self._database_path
364
+
365
+ def initialize(self) -> None:
366
+ """
367
+ Create-or-verify the state database; run once at startup, single-threaded.
368
+
369
+ Creates the database's parent directory if absent, opens (creating the
370
+ file on first run) a connection, and verifies the database in order:
371
+ :func:`_stamp_or_verify_application_id` (a foreign ``application_id`` is
372
+ refused before any other write, so a non-fleetpull file is never
373
+ mutated), then :func:`_enable_wal`, then :func:`_verify_quick_check`.
374
+
375
+ Idempotent: a second call against an already-initialized database
376
+ re-confirms the ``application_id``, WAL, and integrity, then returns.
377
+
378
+ Side Effects:
379
+ Creates the parent directory and the database file on first run;
380
+ stamps ``application_id`` on a fresh database; converts the database
381
+ to WAL mode (persists in the file header).
382
+
383
+ Raises:
384
+ ConfigurationError: The path holds a non-fleetpull SQLite file
385
+ (foreign ``application_id``), the database is corrupt, or the
386
+ filesystem does not support WAL (a non-local filesystem — DESIGN
387
+ §5 requires local disk).
388
+ sqlite3.DatabaseError: The path holds a file that is not a SQLite
389
+ database at all.
390
+ """
391
+ self._database_path.parent.mkdir(parents=True, exist_ok=True)
392
+ connection: sqlite3.Connection = sqlite3.connect(self._database_path)
393
+ try:
394
+ _stamp_or_verify_application_id(connection, self._database_path)
395
+ _enable_wal(connection, self._database_path)
396
+ _verify_quick_check(connection, self._database_path)
397
+ finally:
398
+ connection.close()
399
+ logger.info('State database ready: path=%s', self._database_path)
400
+
401
+ @contextmanager
402
+ def connect(self) -> Iterator[sqlite3.Connection]:
403
+ """
404
+ Open a per-connection-configured connection to the existing database.
405
+
406
+ Opens the database (which must already exist — call :meth:`initialize`
407
+ once at startup first), applies the per-connection pragmas via
408
+ :func:`_apply_connection_pragmas`, yields the connection, and closes it on
409
+ exit. Each thread that touches SQLite calls this for its own connection.
410
+
411
+ This is the plain (read) connection: nothing is committed. A store's
412
+ write runs in :meth:`transaction` instead; the migration runner keeps
413
+ its own explicit ``BEGIN``-based transaction.
414
+
415
+ Yields:
416
+ A ready ``sqlite3.Connection`` with the per-connection pragmas
417
+ applied.
418
+
419
+ Raises:
420
+ RuntimeError: The database file does not exist — :meth:`initialize`
421
+ has not run. Surfaced loudly as a wiring bug rather than silently
422
+ creating an unstamped database.
423
+
424
+ Side Effects:
425
+ Opens and closes a SQLite connection.
426
+ """
427
+ if not self._database_path.exists():
428
+ raise RuntimeError(
429
+ f'state database {self._database_path} does not exist; '
430
+ f'call initialize() before connect()'
431
+ )
432
+ connection: sqlite3.Connection = sqlite3.connect(self._database_path)
433
+ try:
434
+ _apply_connection_pragmas(connection, self._busy_timeout_ms)
435
+ yield connection
436
+ finally:
437
+ connection.close()
438
+
439
+ @contextmanager
440
+ def transaction(self) -> Iterator[sqlite3.Connection]:
441
+ """
442
+ Open a connection whose work commits on clean exit of the block.
443
+
444
+ The stores' shared write policy, stated once: :meth:`connect`, yield,
445
+ and ``commit`` only when the block exits cleanly. An exception
446
+ propagates without committing, and the closing connection discards the
447
+ uncommitted work — a store's raise-before-commit guard therefore never
448
+ persists the refused write. Transactions stay tiny by construction
449
+ (DESIGN §5): one store operation per block, never an HTTP call inside.
450
+
451
+ Yields:
452
+ A ready ``sqlite3.Connection``; everything executed on it commits
453
+ together on clean exit.
454
+
455
+ Raises:
456
+ RuntimeError: Per :meth:`connect`.
457
+
458
+ Side Effects:
459
+ Opens and closes a SQLite connection; commits on clean exit.
460
+ """
461
+ with self.connect() as connection:
462
+ yield connection
463
+ connection.commit()