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,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
|