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,417 @@
|
|
|
1
|
+
# src/fleetpull/state/cursors.py
|
|
2
|
+
"""The cursor persistence layer: translator between cursors and ``cursors``-table rows.
|
|
3
|
+
|
|
4
|
+
Owns the serialization the pure cursor leaf (``incremental/``) and the migration
|
|
5
|
+
runner (``state/migrations.py``) deliberately don't (DESIGN §4/§5). A
|
|
6
|
+
``DateWatermark`` serializes its ``watermark`` to ISO-8601 UTC text via the timing
|
|
7
|
+
codec; a ``FeedToken`` stores its opaque token verbatim (fleetpull never parses
|
|
8
|
+
it). The ``kind`` column discriminates the union arm on read; ``updated_at`` is
|
|
9
|
+
written from the injected ``Clock``. Runs after ``migrate_to_head`` — the
|
|
10
|
+
``cursors`` table must already exist.
|
|
11
|
+
|
|
12
|
+
A row read with an unrecognized ``kind``, or a ``date_watermark`` ``value`` that is
|
|
13
|
+
not parseable ISO-8601 UTC, is state-store corruption and raises
|
|
14
|
+
``ConfigurationError``, consistent with the other §5 corruption stances.
|
|
15
|
+
``get_cursor`` returning ``None`` means exactly "no cursor has been persisted for
|
|
16
|
+
this (provider, endpoint)" — the store never fabricates one and never interprets
|
|
17
|
+
absence; that decision lives in the caller. Two writes, one arm each, both
|
|
18
|
+
kind-guarded inside their statements (so a cursor row can never silently
|
|
19
|
+
change arm — the §5 kind-guard doctrine, total since the feed arm landed
|
|
20
|
+
2026-07-21 and the earlier unguarded general upsert was deleted with it):
|
|
21
|
+
``advance_watermark_forward`` is the watermark arm's write, additionally
|
|
22
|
+
carrying the strictly-forward monotonicity guard in-statement (the recorded
|
|
23
|
+
§5 exception to the dumb-store stance, 2026-07-20), because its concurrent
|
|
24
|
+
prefix-committing callers cannot enforce monotonicity race-free from outside
|
|
25
|
+
the statement; ``commit_feed_token`` is the feed arm's write —
|
|
26
|
+
kind-guarded last-write-wins, with monotonicity deliberately left to the
|
|
27
|
+
caller's serial per-page sequencing (the reasoning on the method).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import sqlite3
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from enum import StrEnum
|
|
34
|
+
from typing import Final
|
|
35
|
+
|
|
36
|
+
from fleetpull.exceptions import ConfigurationError
|
|
37
|
+
from fleetpull.incremental import DateWatermark, FeedToken, IncrementalCursor
|
|
38
|
+
from fleetpull.state.database import (
|
|
39
|
+
SqliteScalar,
|
|
40
|
+
StateDatabase,
|
|
41
|
+
expect_text,
|
|
42
|
+
parse_stored_instant,
|
|
43
|
+
)
|
|
44
|
+
from fleetpull.timing import Clock, to_iso8601
|
|
45
|
+
from fleetpull.vocabulary import Provider
|
|
46
|
+
|
|
47
|
+
__all__: list[str] = ['CursorKind', 'CursorStore']
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CursorKind(StrEnum):
|
|
53
|
+
"""
|
|
54
|
+
The ``cursors.kind`` discriminator: which arm of the union a row holds.
|
|
55
|
+
|
|
56
|
+
The read path dispatches over this discriminator to reconstruct the arm, and
|
|
57
|
+
``CursorKind(kind_text)`` gives a free corrupt-discriminator guard. The string
|
|
58
|
+
values equal the migration's CHECK literals exactly; the two are held in two
|
|
59
|
+
places by deliberate boundary discipline (the migration runner owns its DDL,
|
|
60
|
+
the store owns serialization, and neither imports the other), pinned by the
|
|
61
|
+
round-trip tests that run against a real migrated table with the CHECK active.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
DATE_WATERMARK = 'date_watermark'
|
|
65
|
+
FEED_TOKEN = 'feed_token'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _deserialize_cursor(
|
|
69
|
+
provider: Provider, endpoint: str, kind_text: str, value_text: str
|
|
70
|
+
) -> IncrementalCursor:
|
|
71
|
+
"""
|
|
72
|
+
Reconstruct an incremental cursor from its stored ``(kind, value)`` row.
|
|
73
|
+
|
|
74
|
+
The raising read-side half of the codec: ``kind_text`` discriminates the arm
|
|
75
|
+
and ``value_text`` is deserialized into it. A ``date_watermark`` value is
|
|
76
|
+
parsed back from ISO-8601 UTC via the timing codec; a ``feed_token`` value is
|
|
77
|
+
the opaque token, returned verbatim. A ``kind_text`` outside the two known
|
|
78
|
+
discriminators, or a ``date_watermark`` ``value_text`` that is not parseable
|
|
79
|
+
ISO-8601 UTC, is state-store corruption (the §5 stance) and raises
|
|
80
|
+
``ConfigurationError``.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
provider: The provider whose cursor row this is; identifies the corrupt
|
|
84
|
+
row in a raised error, not otherwise consulted.
|
|
85
|
+
endpoint: The endpoint whose cursor row this is; identifies the corrupt
|
|
86
|
+
row in a raised error, not otherwise consulted.
|
|
87
|
+
kind_text: The row's ``kind`` discriminator column.
|
|
88
|
+
value_text: The row's ``value`` column.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The reconstructed ``DateWatermark`` or ``FeedToken``.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ConfigurationError: ``kind_text`` is not a known cursor kind, or a
|
|
95
|
+
``date_watermark`` ``value_text`` is not parseable ISO-8601 UTC —
|
|
96
|
+
either is state-store corruption.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
kind: CursorKind = CursorKind(kind_text)
|
|
100
|
+
except ValueError as error:
|
|
101
|
+
raise ConfigurationError(
|
|
102
|
+
'state database holds an unrecognized cursor kind',
|
|
103
|
+
provider=provider.value,
|
|
104
|
+
endpoint=endpoint,
|
|
105
|
+
detail=(
|
|
106
|
+
f'cursor kind {kind_text!r} is not one of '
|
|
107
|
+
f'{[member.value for member in CursorKind]}'
|
|
108
|
+
),
|
|
109
|
+
) from error
|
|
110
|
+
match kind:
|
|
111
|
+
case CursorKind.DATE_WATERMARK:
|
|
112
|
+
watermark: datetime = parse_stored_instant(
|
|
113
|
+
value_text,
|
|
114
|
+
provider=provider,
|
|
115
|
+
endpoint=endpoint,
|
|
116
|
+
column='watermark cursor value',
|
|
117
|
+
)
|
|
118
|
+
return DateWatermark(watermark=watermark)
|
|
119
|
+
case CursorKind.FEED_TOKEN:
|
|
120
|
+
return FeedToken(from_version=value_text)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_SELECT_CURSOR_SQL: Final[str] = (
|
|
124
|
+
'SELECT kind, value FROM cursors WHERE provider = ? AND endpoint = ?'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# The feed arm's kind-guarded last-write-wins commit (DESIGN section 5,
|
|
128
|
+
# 2026-07-21) — and the shared upsert skeleton both writes are stated on:
|
|
129
|
+
# the in-statement kind guard keeps a stored watermark
|
|
130
|
+
# untouched (the caller distinguishes that refusal loudly — see
|
|
131
|
+
# commit_feed_token); the value is otherwise overwritten unconditionally.
|
|
132
|
+
# Deliberately NO monotonicity guard, unlike the watermark's: the token is
|
|
133
|
+
# opaque by doctrine (section 8's probe-settled decision 4 — the version
|
|
134
|
+
# order is the provider's, never fleetpull's to compare), a lexical guard
|
|
135
|
+
# would bet on the observed-but-uncontracted 16-hex encoding, and the feed
|
|
136
|
+
# drive is the only writer and strictly serial (one page after another under
|
|
137
|
+
# the single-driver-per-state-database assumption), so there is no
|
|
138
|
+
# interleaving for an in-statement guard to defend against — the situation
|
|
139
|
+
# that justified the watermark exception does not exist here.
|
|
140
|
+
_COMMIT_FEED_TOKEN_SQL: Final[str] = """
|
|
141
|
+
INSERT INTO cursors (provider, endpoint, kind, value, updated_at)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?)
|
|
143
|
+
ON CONFLICT (provider, endpoint) DO UPDATE SET
|
|
144
|
+
value = excluded.value,
|
|
145
|
+
updated_at = excluded.updated_at
|
|
146
|
+
WHERE cursors.kind = excluded.kind
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
# The atomic forward-only advance (DESIGN section 5, 2026-07-20): the feed
|
|
150
|
+
# write's kind-guarded upsert plus the monotonicity conjunct, derived so the
|
|
151
|
+
# skeleton is stated once and only the conjunct distinguishes the arms. The
|
|
152
|
+
# guard lives INSIDE the statement, so concurrent unit
|
|
153
|
+
# completions racing their prefix commits can never interleave a stale
|
|
154
|
+
# read into a backward write. Lexical > on ``to_iso8601``'s fixed-width
|
|
155
|
+
# Z-form is chronological. The kind guard keeps a feed cursor untouched;
|
|
156
|
+
# the caller distinguishes that case loudly (see advance_watermark_forward).
|
|
157
|
+
_ADVANCE_WATERMARK_SQL: Final[str] = (
|
|
158
|
+
_COMMIT_FEED_TOKEN_SQL.rstrip('\n') + ' AND excluded.value > cursors.value\n'
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _stored_kind(
|
|
163
|
+
connection: sqlite3.Connection, provider: Provider, endpoint: str
|
|
164
|
+
) -> SqliteScalar:
|
|
165
|
+
"""Read the stored cursor row's ``kind`` for a refused write's diagnostic.
|
|
166
|
+
|
|
167
|
+
Runs on the refusing write's own connection, inside its still-open
|
|
168
|
+
transaction — the guarded upsert already refused and changed nothing,
|
|
169
|
+
so the read sees exactly the row the guard compared against.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
connection: The refusing write's open connection.
|
|
173
|
+
provider: The provider whose row to read.
|
|
174
|
+
endpoint: The endpoint whose row to read.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The stored ``kind`` scalar, or ``None`` when no row exists.
|
|
178
|
+
"""
|
|
179
|
+
row: tuple[SqliteScalar, SqliteScalar] | None = connection.execute(
|
|
180
|
+
_SELECT_CURSOR_SQL, (provider.value, endpoint)
|
|
181
|
+
).fetchone()
|
|
182
|
+
return None if row is None else row[0]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _guarded_upsert(
|
|
186
|
+
connection: sqlite3.Connection, sql: str, params: tuple[str, ...]
|
|
187
|
+
) -> bool:
|
|
188
|
+
"""Execute one guarded cursor upsert and report whether it wrote.
|
|
189
|
+
|
|
190
|
+
The Python half of the shared upsert skeleton: the guard semantics stay
|
|
191
|
+
entirely inside ``sql`` (the §5 guard-placement doctrine); this helper
|
|
192
|
+
only executes and detects whether the guarded statement changed a row.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
connection: The write's open connection.
|
|
196
|
+
sql: The guarded upsert statement.
|
|
197
|
+
params: The statement's positional bindings.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
``True`` when a row was inserted or updated; ``False`` when the
|
|
201
|
+
in-statement guard refused the write.
|
|
202
|
+
"""
|
|
203
|
+
changes_before: int = connection.total_changes
|
|
204
|
+
connection.execute(sql, params)
|
|
205
|
+
return connection.total_changes > changes_before
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class CursorStore:
|
|
209
|
+
"""
|
|
210
|
+
Persists and reads per-(provider, endpoint) incremental cursors.
|
|
211
|
+
|
|
212
|
+
The translator between the ``IncrementalCursor`` union (§4) and
|
|
213
|
+
``cursors``-table rows: it owns the serialization the cursor leaf and the
|
|
214
|
+
migration runner deliberately don't. Runs after ``migrate_to_head`` (the
|
|
215
|
+
table must exist). ``get_cursor`` reconstructs the tagged-union arm from
|
|
216
|
+
the row's ``kind`` discriminator; the two writes — one per arm, both
|
|
217
|
+
stamped with the injected ``Clock`` — each carry the kind guard inside
|
|
218
|
+
their statement, so a cursor row can never silently change arm (§5's
|
|
219
|
+
kind-guard doctrine): ``advance_watermark_forward`` is the watermark
|
|
220
|
+
arm's strictly-forward advance, and ``commit_feed_token`` is the feed
|
|
221
|
+
arm's last-write-wins commit. No unguarded general write exists (the
|
|
222
|
+
earlier ``set_cursor`` upsert, scaffolding for the then-unbuilt feed arm,
|
|
223
|
+
was deleted when the guarded feed commit landed, 2026-07-21).
|
|
224
|
+
|
|
225
|
+
The store stays deliberately dumb otherwise: it never fabricates a
|
|
226
|
+
cursor and never interprets absence; resume-on-absence policy lives in
|
|
227
|
+
the orchestrator (§5).
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
database: The initialized, migrated state database supplying connections.
|
|
231
|
+
clock: The clock stamping ``updated_at`` on every write.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(self, database: StateDatabase, clock: Clock) -> None:
|
|
235
|
+
self._database: StateDatabase = database
|
|
236
|
+
self._clock: Clock = clock
|
|
237
|
+
|
|
238
|
+
def get_cursor(self, provider: Provider, endpoint: str) -> IncrementalCursor | None:
|
|
239
|
+
"""
|
|
240
|
+
Read the persisted cursor for one (provider, endpoint), if any.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
provider: The provider whose cursor to read.
|
|
244
|
+
endpoint: The endpoint whose cursor to read.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
The reconstructed ``DateWatermark`` or ``FeedToken``, or ``None`` when
|
|
248
|
+
no cursor has been persisted for this (provider, endpoint). ``None``
|
|
249
|
+
means exactly that absence — the store neither fabricates a cursor nor
|
|
250
|
+
interprets the gap; the resume-on-absence decision lives above it (§5).
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
ConfigurationError: The stored row is corrupt — an unrecognized
|
|
254
|
+
``kind`` or an unparseable ``date_watermark`` value.
|
|
255
|
+
RuntimeError: A ``kind`` or ``value`` column came back non-text,
|
|
256
|
+
violating the STRICT ``TEXT NOT NULL`` schema contract.
|
|
257
|
+
|
|
258
|
+
Side Effects:
|
|
259
|
+
Opens a connection and reads one row.
|
|
260
|
+
"""
|
|
261
|
+
with self._database.connect() as connection:
|
|
262
|
+
row: tuple[SqliteScalar, SqliteScalar] | None = connection.execute(
|
|
263
|
+
_SELECT_CURSOR_SQL, (provider.value, endpoint)
|
|
264
|
+
).fetchone()
|
|
265
|
+
if row is None:
|
|
266
|
+
return None
|
|
267
|
+
kind_text, value_text = row
|
|
268
|
+
# The columns are TEXT NOT NULL under STRICT, so non-text is a SQLite
|
|
269
|
+
# contract violation, surfaced loudly (the database.py narrowing pattern).
|
|
270
|
+
return _deserialize_cursor(
|
|
271
|
+
provider,
|
|
272
|
+
endpoint,
|
|
273
|
+
expect_text(kind_text, 'cursors.kind'),
|
|
274
|
+
expect_text(value_text, 'cursors.value'),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def commit_feed_token(
|
|
278
|
+
self, provider: Provider, endpoint: str, to_version: str
|
|
279
|
+
) -> None:
|
|
280
|
+
"""
|
|
281
|
+
Commit the feed cursor to ``to_version`` — kind-guarded last-write-wins.
|
|
282
|
+
|
|
283
|
+
The feed arm's only write (DESIGN §5, 2026-07-21). The kind guard
|
|
284
|
+
lives inside the statement, mirroring the watermark advance: a feed
|
|
285
|
+
token never overwrites a stored watermark (a refused write here is
|
|
286
|
+
always a cross-mode wiring bug, surfaced loudly). There is
|
|
287
|
+
deliberately NO monotonicity guard, unlike the watermark's two
|
|
288
|
+
reasons deep: the token is opaque by doctrine (§8's probe-settled
|
|
289
|
+
decision 4 — a lexical comparison would bet on the observed 16-hex
|
|
290
|
+
encoding GeoTab never contracted), and the feed drive is the only
|
|
291
|
+
writer and strictly serial (per-page commits of a version-ordered
|
|
292
|
+
stream under the single-driver assumption), so no interleaving
|
|
293
|
+
exists for a guard to defend against. Forward motion is therefore
|
|
294
|
+
the protocol's and the caller's property, not the store's;
|
|
295
|
+
last-write-wins is the documented semantic.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
provider: The provider whose feed cursor to commit.
|
|
299
|
+
endpoint: The endpoint whose feed cursor to commit.
|
|
300
|
+
to_version: The page's ``toVersion`` — the opaque resume token,
|
|
301
|
+
stored verbatim (fleetpull never parses it). Re-committing
|
|
302
|
+
the stored value (the at-head empty page) is a valid no-op
|
|
303
|
+
rewrite.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
ConfigurationError: The stored cursor is a date watermark — a
|
|
307
|
+
cross-mode write is a wiring bug upstream, surfaced loudly
|
|
308
|
+
rather than silently skipped.
|
|
309
|
+
|
|
310
|
+
Side Effects:
|
|
311
|
+
Opens a connection; inserts or overwrites at most one row; commits.
|
|
312
|
+
"""
|
|
313
|
+
updated_at: str = to_iso8601(self._clock.now_utc())
|
|
314
|
+
with self._database.transaction() as connection:
|
|
315
|
+
committed: bool = _guarded_upsert(
|
|
316
|
+
connection,
|
|
317
|
+
_COMMIT_FEED_TOKEN_SQL,
|
|
318
|
+
(
|
|
319
|
+
provider.value,
|
|
320
|
+
endpoint,
|
|
321
|
+
CursorKind.FEED_TOKEN.value,
|
|
322
|
+
to_version,
|
|
323
|
+
updated_at,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
if not committed:
|
|
327
|
+
# Last-write-wins can only be refused by the kind guard, so a
|
|
328
|
+
# refusal is always the cross-mode bug; the diagnostic read
|
|
329
|
+
# names the stored kind.
|
|
330
|
+
stored_kind = _stored_kind(connection, provider, endpoint)
|
|
331
|
+
raise ConfigurationError(
|
|
332
|
+
'cross-mode feed-token commit refused',
|
|
333
|
+
provider=provider.value,
|
|
334
|
+
endpoint=endpoint,
|
|
335
|
+
detail=f'stored cursor kind is {stored_kind!r}, not a feed token',
|
|
336
|
+
)
|
|
337
|
+
logger.debug(
|
|
338
|
+
'feed token committed: provider=%s endpoint=%s to_version=%s',
|
|
339
|
+
provider.value,
|
|
340
|
+
endpoint,
|
|
341
|
+
to_version,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def advance_watermark_forward(
|
|
345
|
+
self, provider: Provider, endpoint: str, observed: datetime
|
|
346
|
+
) -> bool:
|
|
347
|
+
"""
|
|
348
|
+
Advance the date watermark to ``observed`` iff that is strictly forward.
|
|
349
|
+
|
|
350
|
+
The one write with the monotonicity guard INSIDE the statement (the
|
|
351
|
+
deliberate exception to this store's no-discipline stance, added with
|
|
352
|
+
the prefix-advance rule -- DESIGN §5, 2026-07-20): concurrent unit
|
|
353
|
+
completions race their prefix commits, and a read-compare-write in the
|
|
354
|
+
caller could interleave a stale read into a backward write. The
|
|
355
|
+
guarded upsert inserts when no cursor exists, advances when
|
|
356
|
+
``observed`` is strictly beyond the stored watermark, and changes
|
|
357
|
+
nothing otherwise -- atomically, whatever the caller interleaving.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
provider: The provider whose watermark to advance.
|
|
361
|
+
endpoint: The endpoint whose watermark to advance.
|
|
362
|
+
observed: The candidate watermark -- a folded in-window maximum
|
|
363
|
+
event time.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
``True`` when the cursor row was inserted or advanced; ``False``
|
|
367
|
+
when ``observed`` was not strictly forward of the stored value.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
ValueError: ``observed`` is naive or not UTC (surfaced from the
|
|
371
|
+
timing codec).
|
|
372
|
+
ConfigurationError: The stored cursor is a feed token -- a
|
|
373
|
+
cross-mode write is a wiring bug upstream, surfaced loudly
|
|
374
|
+
rather than silently skipped.
|
|
375
|
+
|
|
376
|
+
Side Effects:
|
|
377
|
+
Opens a connection; inserts or updates at most one row; commits.
|
|
378
|
+
"""
|
|
379
|
+
value: str = to_iso8601(observed)
|
|
380
|
+
updated_at: str = to_iso8601(self._clock.now_utc())
|
|
381
|
+
with self._database.transaction() as connection:
|
|
382
|
+
advanced: bool = _guarded_upsert(
|
|
383
|
+
connection,
|
|
384
|
+
_ADVANCE_WATERMARK_SQL,
|
|
385
|
+
(
|
|
386
|
+
provider.value,
|
|
387
|
+
endpoint,
|
|
388
|
+
CursorKind.DATE_WATERMARK.value,
|
|
389
|
+
value,
|
|
390
|
+
updated_at,
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
if not advanced:
|
|
394
|
+
# Not-forward is normal; a kind mismatch is a bug. One
|
|
395
|
+
# diagnostic read distinguishes them.
|
|
396
|
+
stored_kind = _stored_kind(connection, provider, endpoint)
|
|
397
|
+
if (
|
|
398
|
+
stored_kind is not None
|
|
399
|
+
and stored_kind != CursorKind.DATE_WATERMARK.value
|
|
400
|
+
):
|
|
401
|
+
raise ConfigurationError(
|
|
402
|
+
'cross-mode watermark advance refused',
|
|
403
|
+
provider=provider.value,
|
|
404
|
+
endpoint=endpoint,
|
|
405
|
+
detail=(
|
|
406
|
+
f'stored cursor kind is {stored_kind!r}, not a '
|
|
407
|
+
f'date watermark'
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
if advanced:
|
|
411
|
+
logger.debug(
|
|
412
|
+
'watermark advanced: provider=%s endpoint=%s watermark=%s',
|
|
413
|
+
provider.value,
|
|
414
|
+
endpoint,
|
|
415
|
+
value,
|
|
416
|
+
)
|
|
417
|
+
return advanced
|