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.
- fleetpull/__init__.py +36 -0
- fleetpull/api/__init__.py +32 -0
- fleetpull/api/auth_ingress.py +216 -0
- fleetpull/api/catalog.py +139 -0
- fleetpull/api/fetch.py +229 -0
- fleetpull/api/identity.py +90 -0
- fleetpull/api/sync.py +735 -0
- fleetpull/cli.py +146 -0
- fleetpull/config/__init__.py +47 -0
- fleetpull/config/base.py +18 -0
- fleetpull/config/example.py +90 -0
- fleetpull/config/geotab.py +78 -0
- fleetpull/config/http.py +32 -0
- fleetpull/config/loading.py +195 -0
- fleetpull/config/logger.py +139 -0
- fleetpull/config/providers.py +520 -0
- fleetpull/config/rate_limit.py +50 -0
- fleetpull/config/resolution.py +156 -0
- fleetpull/config/retry.py +69 -0
- fleetpull/config/root.py +157 -0
- fleetpull/config/sections.py +146 -0
- fleetpull/endpoints/__init__.py +24 -0
- fleetpull/endpoints/geotab/__init__.py +12 -0
- fleetpull/endpoints/geotab/_requests.py +427 -0
- fleetpull/endpoints/geotab/annotation_logs.py +73 -0
- fleetpull/endpoints/geotab/audits.py +70 -0
- fleetpull/endpoints/geotab/devices.py +79 -0
- fleetpull/endpoints/geotab/driver_changes.py +73 -0
- fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
- fleetpull/endpoints/geotab/dvir_logs.py +76 -0
- fleetpull/endpoints/geotab/exception_events.py +119 -0
- fleetpull/endpoints/geotab/fault_data.py +72 -0
- fleetpull/endpoints/geotab/fill_ups.py +79 -0
- fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
- fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
- fleetpull/endpoints/geotab/log_records.py +74 -0
- fleetpull/endpoints/geotab/media_files.py +74 -0
- fleetpull/endpoints/geotab/shipment_logs.py +71 -0
- fleetpull/endpoints/geotab/status_data.py +71 -0
- fleetpull/endpoints/geotab/text_messages.py +77 -0
- fleetpull/endpoints/geotab/trips.py +102 -0
- fleetpull/endpoints/geotab/users.py +81 -0
- fleetpull/endpoints/motive/__init__.py +12 -0
- fleetpull/endpoints/motive/_spec_builders.py +94 -0
- fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
- fleetpull/endpoints/motive/driving_periods.py +85 -0
- fleetpull/endpoints/motive/groups.py +61 -0
- fleetpull/endpoints/motive/idle_events.py +94 -0
- fleetpull/endpoints/motive/users.py +64 -0
- fleetpull/endpoints/motive/vehicle_locations.py +170 -0
- fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
- fleetpull/endpoints/motive/vehicles.py +106 -0
- fleetpull/endpoints/registry.py +280 -0
- fleetpull/endpoints/samsara/__init__.py +12 -0
- fleetpull/endpoints/samsara/_spec_builders.py +202 -0
- fleetpull/endpoints/samsara/addresses.py +77 -0
- fleetpull/endpoints/samsara/asset_locations.py +217 -0
- fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
- fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
- fleetpull/endpoints/samsara/drivers.py +169 -0
- fleetpull/endpoints/samsara/engine_states.py +114 -0
- fleetpull/endpoints/samsara/gps_readings.py +113 -0
- fleetpull/endpoints/samsara/idling_events.py +195 -0
- fleetpull/endpoints/samsara/odometer_readings.py +116 -0
- fleetpull/endpoints/samsara/trips.py +217 -0
- fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
- fleetpull/endpoints/samsara/vehicles.py +121 -0
- fleetpull/endpoints/shared/__init__.py +57 -0
- fleetpull/endpoints/shared/base.py +529 -0
- fleetpull/endpoints/shared/request_shape.py +227 -0
- fleetpull/endpoints/shared/resume.py +74 -0
- fleetpull/endpoints/shared/spec_builders.py +59 -0
- fleetpull/endpoints/shared/sync_mode.py +159 -0
- fleetpull/endpoints/shared/url_paths.py +149 -0
- fleetpull/exceptions.py +320 -0
- fleetpull/incremental/__init__.py +27 -0
- fleetpull/incremental/cursor.py +66 -0
- fleetpull/incremental/resolution.py +152 -0
- fleetpull/incremental/seed.py +53 -0
- fleetpull/incremental/window.py +94 -0
- fleetpull/logger/__init__.py +5 -0
- fleetpull/logger/setup.py +145 -0
- fleetpull/model_contract/__init__.py +6 -0
- fleetpull/model_contract/coercions.py +33 -0
- fleetpull/model_contract/response.py +45 -0
- fleetpull/models/__init__.py +7 -0
- fleetpull/models/geotab/__init__.py +166 -0
- fleetpull/models/geotab/annotation_log.py +109 -0
- fleetpull/models/geotab/audit.py +56 -0
- fleetpull/models/geotab/device.py +217 -0
- fleetpull/models/geotab/driver_change.py +102 -0
- fleetpull/models/geotab/duty_status_log.py +200 -0
- fleetpull/models/geotab/dvir_log.py +184 -0
- fleetpull/models/geotab/exception_event.py +135 -0
- fleetpull/models/geotab/fault_data.py +168 -0
- fleetpull/models/geotab/fill_up.py +189 -0
- fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
- fleetpull/models/geotab/fuel_tax_detail.py +150 -0
- fleetpull/models/geotab/log_record.py +68 -0
- fleetpull/models/geotab/media_file.py +133 -0
- fleetpull/models/geotab/shared.py +221 -0
- fleetpull/models/geotab/shipment_log.py +105 -0
- fleetpull/models/geotab/status_data.py +108 -0
- fleetpull/models/geotab/text_message.py +125 -0
- fleetpull/models/geotab/trip.py +162 -0
- fleetpull/models/geotab/user.py +187 -0
- fleetpull/models/motive/__init__.py +43 -0
- fleetpull/models/motive/driver_idle_rollup.py +109 -0
- fleetpull/models/motive/driving_period.py +82 -0
- fleetpull/models/motive/group.py +49 -0
- fleetpull/models/motive/idle_event.py +77 -0
- fleetpull/models/motive/shared.py +192 -0
- fleetpull/models/motive/user.py +217 -0
- fleetpull/models/motive/vehicle.py +162 -0
- fleetpull/models/motive/vehicle_location.py +127 -0
- fleetpull/models/motive/vehicle_utilization.py +128 -0
- fleetpull/models/samsara/__init__.py +108 -0
- fleetpull/models/samsara/address.py +149 -0
- fleetpull/models/samsara/asset_location.py +131 -0
- fleetpull/models/samsara/driver.py +232 -0
- fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
- fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
- fleetpull/models/samsara/engine_state.py +92 -0
- fleetpull/models/samsara/gps_reading.py +146 -0
- fleetpull/models/samsara/idling_event.py +174 -0
- fleetpull/models/samsara/odometer_reading.py +90 -0
- fleetpull/models/samsara/trip.py +199 -0
- fleetpull/models/samsara/vehicle.py +167 -0
- fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
- fleetpull/network/__init__.py +9 -0
- fleetpull/network/auth/__init__.py +15 -0
- fleetpull/network/auth/authenticate.py +271 -0
- fleetpull/network/auth/manager.py +223 -0
- fleetpull/network/auth/models.py +57 -0
- fleetpull/network/auth/strategies.py +169 -0
- fleetpull/network/classifiers/__init__.py +11 -0
- fleetpull/network/classifiers/geotab.py +145 -0
- fleetpull/network/classifiers/motive.py +79 -0
- fleetpull/network/classifiers/samsara.py +80 -0
- fleetpull/network/client/__init__.py +25 -0
- fleetpull/network/client/page.py +32 -0
- fleetpull/network/client/profile.py +27 -0
- fleetpull/network/client/registry.py +103 -0
- fleetpull/network/client/registry_base.py +150 -0
- fleetpull/network/client/runtime.py +44 -0
- fleetpull/network/client/transport.py +381 -0
- fleetpull/network/contract/__init__.py +50 -0
- fleetpull/network/contract/auth.py +48 -0
- fleetpull/network/contract/classifier.py +189 -0
- fleetpull/network/contract/envelope_fetcher.py +33 -0
- fleetpull/network/contract/envelopes.py +189 -0
- fleetpull/network/contract/outcome.py +43 -0
- fleetpull/network/contract/page_decoder.py +101 -0
- fleetpull/network/contract/request.py +97 -0
- fleetpull/network/decoders/__init__.py +40 -0
- fleetpull/network/decoders/_window_stamp.py +78 -0
- fleetpull/network/decoders/geotab.py +309 -0
- fleetpull/network/decoders/motive.py +204 -0
- fleetpull/network/decoders/motive_reports.py +114 -0
- fleetpull/network/decoders/samsara.py +343 -0
- fleetpull/network/decoders/samsara_reports.py +119 -0
- fleetpull/network/decoders/single_page.py +55 -0
- fleetpull/network/limits/__init__.py +13 -0
- fleetpull/network/limits/bucket_math.py +58 -0
- fleetpull/network/limits/limiter.py +153 -0
- fleetpull/network/limits/registry.py +96 -0
- fleetpull/network/posture/__init__.py +6 -0
- fleetpull/network/posture/client_options.py +96 -0
- fleetpull/network/retry/__init__.py +13 -0
- fleetpull/network/retry/decision.py +155 -0
- fleetpull/network/tls/__init__.py +5 -0
- fleetpull/network/tls/truststore_context.py +35 -0
- fleetpull/orchestrator/__init__.py +30 -0
- fleetpull/orchestrator/backfill.py +127 -0
- fleetpull/orchestrator/batch.py +155 -0
- fleetpull/orchestrator/bisection.py +271 -0
- fleetpull/orchestrator/drivers.py +350 -0
- fleetpull/orchestrator/entry.py +347 -0
- fleetpull/orchestrator/executors.py +128 -0
- fleetpull/orchestrator/fanout.py +163 -0
- fleetpull/orchestrator/feed_drive.py +266 -0
- fleetpull/orchestrator/metadata_projection.py +195 -0
- fleetpull/orchestrator/outcome.py +53 -0
- fleetpull/orchestrator/recording.py +85 -0
- fleetpull/orchestrator/resume.py +143 -0
- fleetpull/orchestrator/roster_harvest.py +108 -0
- fleetpull/orchestrator/roster_refresh.py +363 -0
- fleetpull/orchestrator/runner.py +262 -0
- fleetpull/orchestrator/shape_resolution.py +221 -0
- fleetpull/orchestrator/spine.py +166 -0
- fleetpull/orchestrator/streaming.py +153 -0
- fleetpull/orchestrator/unit_loop.py +273 -0
- fleetpull/orchestrator/watermark_drive.py +455 -0
- fleetpull/paths/__init__.py +13 -0
- fleetpull/paths/datasets.py +38 -0
- fleetpull/paths/partitions.py +41 -0
- fleetpull/paths/resolution.py +98 -0
- fleetpull/polars_typing/__init__.py +20 -0
- fleetpull/py.typed +0 -0
- fleetpull/records/__init__.py +19 -0
- fleetpull/records/convert.py +53 -0
- fleetpull/records/dataframe.py +63 -0
- fleetpull/records/event_time.py +70 -0
- fleetpull/records/fields.py +190 -0
- fleetpull/records/flatten.py +54 -0
- fleetpull/records/roster_members.py +74 -0
- fleetpull/records/schema.py +73 -0
- fleetpull/records/validation.py +62 -0
- fleetpull/resources/__init__.py +10 -0
- fleetpull/resources/config.example.yaml +222 -0
- fleetpull/roster/__init__.py +16 -0
- fleetpull/roster/definition.py +46 -0
- fleetpull/roster/key.py +40 -0
- fleetpull/roster/registry.py +93 -0
- fleetpull/state/__init__.py +36 -0
- fleetpull/state/cursors.py +417 -0
- fleetpull/state/database.py +463 -0
- fleetpull/state/migrations.py +430 -0
- fleetpull/state/reconcile.py +127 -0
- fleetpull/state/rosters.py +159 -0
- fleetpull/state/run_ledger.py +572 -0
- fleetpull/state/work_units.py +648 -0
- fleetpull/storage/__init__.py +30 -0
- fleetpull/storage/append.py +187 -0
- fleetpull/storage/atomic.py +161 -0
- fleetpull/storage/files.py +176 -0
- fleetpull/storage/frames.py +98 -0
- fleetpull/storage/metadata.py +161 -0
- fleetpull/storage/partitioned.py +205 -0
- fleetpull/storage/pruning.py +169 -0
- fleetpull/storage/read.py +35 -0
- fleetpull/storage/result.py +34 -0
- fleetpull/storage/single_file.py +130 -0
- fleetpull/storage/splitting.py +88 -0
- fleetpull/storage/staging.py +178 -0
- fleetpull/storage/writers.py +145 -0
- fleetpull/timing/__init__.py +21 -0
- fleetpull/timing/canon.py +92 -0
- fleetpull/timing/clock.py +192 -0
- fleetpull/timing/codec.py +122 -0
- fleetpull/timing/sleeper.py +65 -0
- fleetpull/vocabulary/__init__.py +16 -0
- fleetpull/vocabulary/json_types.py +20 -0
- fleetpull/vocabulary/provider.py +29 -0
- fleetpull/vocabulary/quota_scope.py +47 -0
- fleetpull/vocabulary/response_category.py +32 -0
- fleetpull-0.1.0.dist-info/METADATA +248 -0
- fleetpull-0.1.0.dist-info/RECORD +252 -0
- fleetpull-0.1.0.dist-info/WHEEL +5 -0
- fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
- fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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()
|