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,572 @@
|
|
|
1
|
+
# src/fleetpull/state/run_ledger.py
|
|
2
|
+
"""The run ledger: the operational record of every fetch and the coverage frontier.
|
|
3
|
+
|
|
4
|
+
One row per run — one fetch of one (provider, endpoint) in one of three sync modes:
|
|
5
|
+
a *snapshot* (no range — a full current-state refetch), a *watermark* window
|
|
6
|
+
(``window_start``/``window_end``), or a *feed* version range
|
|
7
|
+
(``from_version``/``to_version``). A ``mode`` column records which, so the row is
|
|
8
|
+
self-describing; the range columns a run populates follow its mode. A sync
|
|
9
|
+
invocation produces many runs; incremental and backfill-chunk fetches alike record
|
|
10
|
+
one, so the ledger is the single coverage source (DESIGN §5). Runs after
|
|
11
|
+
``migrate_to_head`` — the ``runs`` table must already exist.
|
|
12
|
+
|
|
13
|
+
Two-phase lifecycle: one of ``start_snapshot_run`` / ``start_window_run`` /
|
|
14
|
+
``start_feed_run`` inserts a ``running`` row stamped from the injected ``Clock``
|
|
15
|
+
with the range shape its mode requires — three single-shape entry points, so an
|
|
16
|
+
impossible arm combination cannot be expressed; ``complete_run`` closes it
|
|
17
|
+
``succeeded`` with the row count (and a feed run's end ``toVersion``); ``fail_run``
|
|
18
|
+
closes it ``failed`` with an error detail. The mode-keyed arm shape, a non-negative
|
|
19
|
+
row count, and a well-ordered window are guarded both here (each entry point owns
|
|
20
|
+
its shape) and by the table's CHECK constraints (the structural backstop),
|
|
21
|
+
mirroring the cursor store's two-places-by-discipline split.
|
|
22
|
+
|
|
23
|
+
``coverage_frontier`` reads ``max(window_end)`` over an endpoint's ``succeeded``
|
|
24
|
+
runs — the implementation of DESIGN §4/§5 resume arm (2). It is watermark-only:
|
|
25
|
+
feed and snapshot endpoints never reach this arm (a feed endpoint holds a
|
|
26
|
+
committed cursor; a snapshot has no resume). A stored
|
|
27
|
+
``window_end`` that is not parseable ISO-8601 UTC is state-store corruption and
|
|
28
|
+
raises ``ConfigurationError``, the same stance as the cursor store. A crashed
|
|
29
|
+
run's stale ``running`` row is diagnostic only — the frontier filters
|
|
30
|
+
``succeeded`` — and reaping it is deferred.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import sqlite3
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from enum import StrEnum
|
|
38
|
+
from typing import Final
|
|
39
|
+
|
|
40
|
+
from fleetpull.exceptions import ConfigurationError
|
|
41
|
+
from fleetpull.incremental import DateWindow
|
|
42
|
+
from fleetpull.state.database import (
|
|
43
|
+
SqliteScalar,
|
|
44
|
+
StateDatabase,
|
|
45
|
+
expect_text,
|
|
46
|
+
parse_stored_instant,
|
|
47
|
+
)
|
|
48
|
+
from fleetpull.timing import Clock, to_iso8601
|
|
49
|
+
from fleetpull.vocabulary import Provider
|
|
50
|
+
|
|
51
|
+
__all__: list[str] = ['RunLedger', 'RunMode', 'RunStatus']
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RunStatus(StrEnum):
|
|
57
|
+
"""
|
|
58
|
+
The ``runs.status`` lifecycle value: ``running`` then ``succeeded`` / ``failed``.
|
|
59
|
+
|
|
60
|
+
The store writes and filters on this closed set, so centralizing the three
|
|
61
|
+
literals keeps them out of scattered string form. The values equal the schema
|
|
62
|
+
CHECK literals exactly; the two are held in two places by the same boundary
|
|
63
|
+
discipline as ``CursorKind`` (the migration runner owns its DDL, the store owns
|
|
64
|
+
its writes, neither imports the other), pinned by the round-trip tests against a
|
|
65
|
+
real migrated table with the CHECK active.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
RUNNING = 'running'
|
|
69
|
+
SUCCEEDED = 'succeeded'
|
|
70
|
+
FAILED = 'failed'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RunMode(StrEnum):
|
|
74
|
+
"""
|
|
75
|
+
The ``runs.mode`` discriminator: which sync mode produced the run.
|
|
76
|
+
|
|
77
|
+
The persisted shadow of the endpoints layer's ``SyncMode`` variant —
|
|
78
|
+
``SnapshotMode`` -> ``SNAPSHOT``, ``WatermarkMode`` -> ``WATERMARK``,
|
|
79
|
+
``FeedMode`` -> ``FEED`` — recorded so the row is self-describing: a reader
|
|
80
|
+
knows which range columns to expect without inferring from null patterns, and
|
|
81
|
+
``complete_run`` dispatches on it (only a feed run carries a ``to_version``). A
|
|
82
|
+
StrEnum here, not the ``SyncMode`` union: ``SyncMode`` carries config
|
|
83
|
+
(``WatermarkMode``'s lookback) and lives in ``endpoints/``, above ``state/``,
|
|
84
|
+
so the ledger cannot import it; the orchestrator translates ``SyncMode`` to this
|
|
85
|
+
tag when it records the run. The values equal the schema CHECK literals exactly,
|
|
86
|
+
held in two places by the same boundary discipline as ``RunStatus`` and
|
|
87
|
+
``CursorKind``.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
SNAPSHOT = 'snapshot'
|
|
91
|
+
WATERMARK = 'watermark'
|
|
92
|
+
FEED = 'feed'
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_INSERT_RUN_SQL: Final[str] = """
|
|
96
|
+
INSERT INTO runs (
|
|
97
|
+
provider, endpoint, status, mode,
|
|
98
|
+
window_start, window_end, from_version, started_at
|
|
99
|
+
)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
_SELECT_RUN_MODE_SQL: Final[str] = 'SELECT mode FROM runs WHERE run_id = ?'
|
|
104
|
+
|
|
105
|
+
_COMPLETE_NONFEED_RUN_SQL: Final[str] = """
|
|
106
|
+
UPDATE runs
|
|
107
|
+
SET status = ?, ended_at = ?, row_count = ?
|
|
108
|
+
WHERE run_id = ?
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
_COMPLETE_FEED_RUN_SQL: Final[str] = """
|
|
112
|
+
UPDATE runs
|
|
113
|
+
SET status = ?, ended_at = ?, row_count = ?, to_version = ?
|
|
114
|
+
WHERE run_id = ?
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
_FAIL_RUN_SQL: Final[str] = """
|
|
118
|
+
UPDATE runs
|
|
119
|
+
SET status = ?, ended_at = ?, error_detail = ?
|
|
120
|
+
WHERE run_id = ?
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
_COVERAGE_FRONTIER_SQL: Final[str] = """
|
|
124
|
+
SELECT max(window_end) FROM runs
|
|
125
|
+
WHERE provider = ? AND endpoint = ? AND status = ? AND window_end IS NOT NULL
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
_LAST_SUCCESS_SQL: Final[str] = """
|
|
129
|
+
SELECT max(ended_at) FROM runs
|
|
130
|
+
WHERE provider = ? AND endpoint = ? AND status = ?
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class _RunRange:
|
|
136
|
+
"""
|
|
137
|
+
The mode-keyed range columns a ``runs`` row carries at insert.
|
|
138
|
+
|
|
139
|
+
The three ``start_*_run`` entry points each build the range shape their mode
|
|
140
|
+
requires — a watermark window, a feed start version, or (snapshot) nothing —
|
|
141
|
+
and hand it to :meth:`RunLedger._insert_run` as one value instead of three
|
|
142
|
+
parallel parameters. All fields default to ``None``; the table's mode-keyed
|
|
143
|
+
CHECK is the structural backstop on the combination.
|
|
144
|
+
|
|
145
|
+
Attributes:
|
|
146
|
+
window_start_text: Serialized watermark window start, or ``None``.
|
|
147
|
+
window_end_text: Serialized watermark window end, or ``None``.
|
|
148
|
+
from_version: Feed start version, or ``None``.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
window_start_text: str | None = None
|
|
152
|
+
window_end_text: str | None = None
|
|
153
|
+
from_version: str | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _read_run_mode(connection: sqlite3.Connection, run_id: int) -> RunMode:
|
|
157
|
+
"""
|
|
158
|
+
Read a run's stored ``mode`` and narrow it to a :class:`RunMode`.
|
|
159
|
+
|
|
160
|
+
The read half of :meth:`RunLedger.complete_run`'s mode dispatch, on the
|
|
161
|
+
caller's already-open connection so the read and the closing UPDATE share
|
|
162
|
+
one transaction.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
connection: An open state-database connection; nothing is committed here.
|
|
166
|
+
run_id: The run whose mode to read.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The stored mode as a :class:`RunMode`.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ValueError: No run has this ``run_id`` — a caller bug, kept stdlib.
|
|
173
|
+
ConfigurationError: The stored ``mode`` is not a recognized ``RunMode``
|
|
174
|
+
— state-store corruption, the same stance as the cursor store.
|
|
175
|
+
RuntimeError: The stored ``mode`` came back non-text, violating the
|
|
176
|
+
STRICT ``TEXT`` schema contract.
|
|
177
|
+
|
|
178
|
+
Side Effects:
|
|
179
|
+
Reads one row on ``connection``.
|
|
180
|
+
"""
|
|
181
|
+
mode_row = connection.execute(_SELECT_RUN_MODE_SQL, (run_id,)).fetchone()
|
|
182
|
+
if mode_row is None:
|
|
183
|
+
raise ValueError(f'no run with run_id {run_id}')
|
|
184
|
+
stored_mode: str = expect_text(mode_row[0], 'runs.mode')
|
|
185
|
+
try:
|
|
186
|
+
return RunMode(stored_mode)
|
|
187
|
+
except ValueError as error:
|
|
188
|
+
raise ConfigurationError(
|
|
189
|
+
'state database holds an unrecognized run mode',
|
|
190
|
+
detail=(
|
|
191
|
+
f'run {run_id} mode {stored_mode!r} is not one of '
|
|
192
|
+
f'{[member.value for member in RunMode]}'
|
|
193
|
+
),
|
|
194
|
+
) from error
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _max_over_succeeded_runs(
|
|
198
|
+
database: StateDatabase,
|
|
199
|
+
sql: str,
|
|
200
|
+
provider: Provider,
|
|
201
|
+
endpoint: str,
|
|
202
|
+
*,
|
|
203
|
+
column: str,
|
|
204
|
+
) -> datetime | None:
|
|
205
|
+
"""
|
|
206
|
+
Run a ``max(...)``-over-``succeeded``-runs aggregate and parse its instant.
|
|
207
|
+
|
|
208
|
+
The shared tail of :meth:`RunLedger.coverage_frontier` and
|
|
209
|
+
:meth:`RunLedger.last_success_at`: execute ``sql`` bound to (provider,
|
|
210
|
+
endpoint, ``succeeded``), narrow the aggregate's single nullable TEXT
|
|
211
|
+
column, and parse it from ISO-8601 UTC. ``column`` names the aggregated
|
|
212
|
+
``runs`` column in the raised errors, so each caller keeps its own prose
|
|
213
|
+
(``window_end`` / ``ended_at``).
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
database: The state database supplying the connection.
|
|
217
|
+
sql: The aggregate query; must bind exactly ``(provider, endpoint,
|
|
218
|
+
status)`` and return one row with one nullable TEXT column.
|
|
219
|
+
provider: The provider whose runs to aggregate.
|
|
220
|
+
endpoint: The endpoint whose runs to aggregate.
|
|
221
|
+
column: The aggregated ``runs`` column's name, used in error prose.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The parsed UTC datetime, or ``None`` when the aggregate's column is
|
|
225
|
+
NULL (no succeeded run qualifies).
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ConfigurationError: The stored value is not parseable ISO-8601 UTC —
|
|
229
|
+
state-store corruption, the same stance as the cursor store.
|
|
230
|
+
RuntimeError: The aggregated value came back non-text, violating the
|
|
231
|
+
STRICT ``TEXT`` schema contract.
|
|
232
|
+
|
|
233
|
+
Side Effects:
|
|
234
|
+
Opens a connection and reads one aggregate row.
|
|
235
|
+
"""
|
|
236
|
+
with database.connect() as connection:
|
|
237
|
+
aggregate_row = connection.execute(
|
|
238
|
+
sql, (provider.value, endpoint, RunStatus.SUCCEEDED.value)
|
|
239
|
+
).fetchone()
|
|
240
|
+
# A bare max() always returns exactly one row; its single column is NULL
|
|
241
|
+
# when no succeeded run qualifies.
|
|
242
|
+
instant_text: SqliteScalar = aggregate_row[0]
|
|
243
|
+
if instant_text is None:
|
|
244
|
+
return None
|
|
245
|
+
return parse_stored_instant(
|
|
246
|
+
expect_text(instant_text, f'runs.{column}'),
|
|
247
|
+
provider=provider,
|
|
248
|
+
endpoint=endpoint,
|
|
249
|
+
column=f'run {column}',
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class RunLedger:
|
|
254
|
+
"""
|
|
255
|
+
Records each fetch run and answers the coverage frontier (DESIGN §5).
|
|
256
|
+
|
|
257
|
+
One row per run — one fetch of one (provider, endpoint) in one of three sync
|
|
258
|
+
modes (snapshot/watermark/feed), recorded in a ``mode`` column. The two-phase
|
|
259
|
+
lifecycle is :meth:`start_snapshot_run` / :meth:`start_window_run` /
|
|
260
|
+
:meth:`start_feed_run` → :meth:`complete_run` / :meth:`fail_run`. The mode-keyed
|
|
261
|
+
arm shape, a non-negative row count, and a well-ordered window are enforced both
|
|
262
|
+
by the per-mode entry points here and by the table's CHECK constraints (the
|
|
263
|
+
structural backstop). :meth:`coverage_frontier` is the watermark-only
|
|
264
|
+
implementation of resume arm (2); feed and snapshot endpoints never reach it.
|
|
265
|
+
Runs after ``migrate_to_head`` (the ``runs`` table must exist).
|
|
266
|
+
``row_count`` uniformly means "records the run produced"; the sink those
|
|
267
|
+
records landed in (a roster for a coordinator harvest, parquet for a
|
|
268
|
+
runner-driven fetch) follows from the run's mode and origin.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
database: The initialized, migrated state database supplying connections.
|
|
272
|
+
clock: The clock stamping ``started_at`` and ``ended_at``.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(self, database: StateDatabase, clock: Clock) -> None:
|
|
276
|
+
self._database: StateDatabase = database
|
|
277
|
+
self._clock: Clock = clock
|
|
278
|
+
|
|
279
|
+
def start_snapshot_run(self, provider: Provider, endpoint: str) -> int:
|
|
280
|
+
"""
|
|
281
|
+
Open a snapshot run: a ``running`` row with no range (``mode='snapshot'``).
|
|
282
|
+
|
|
283
|
+
A snapshot re-fetches the endpoint's full current state every run, so the
|
|
284
|
+
row carries no window and no version. The mode-keyed CHECK is the structural
|
|
285
|
+
backstop.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
provider: The provider being fetched.
|
|
289
|
+
endpoint: The endpoint being fetched.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
The new run's ``run_id`` (the table's rowid alias).
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
RuntimeError: The INSERT returned no ``lastrowid`` — a SQLite contract
|
|
296
|
+
violation, surfaced loudly.
|
|
297
|
+
|
|
298
|
+
Side Effects:
|
|
299
|
+
Opens a connection, inserts one row, and commits.
|
|
300
|
+
"""
|
|
301
|
+
return self._insert_run(provider, endpoint, RunMode.SNAPSHOT, _RunRange())
|
|
302
|
+
|
|
303
|
+
def start_window_run(
|
|
304
|
+
self, provider: Provider, endpoint: str, *, window: DateWindow
|
|
305
|
+
) -> int:
|
|
306
|
+
"""
|
|
307
|
+
Open a watermark run over ``window`` (``mode='watermark'``).
|
|
308
|
+
|
|
309
|
+
``window`` is the half-open resume window the fetch covers, serialized
|
|
310
|
+
via the timing codec. Well-orderedness is the ``DateWindow``'s own
|
|
311
|
+
construction invariant (``start < end``), so no ordering re-check
|
|
312
|
+
happens here; the table's CHECK remains the structural backstop.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
provider: The provider being fetched.
|
|
316
|
+
endpoint: The endpoint being fetched.
|
|
317
|
+
window: The run's half-open resume window, both bounds UTC.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
The new run's ``run_id`` (the table's rowid alias).
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
ValueError: A bound is naive or not UTC (surfaced from the timing
|
|
324
|
+
codec) — a caller bug, kept stdlib.
|
|
325
|
+
RuntimeError: The INSERT returned no ``lastrowid``.
|
|
326
|
+
|
|
327
|
+
Side Effects:
|
|
328
|
+
Opens a connection, inserts one row, and commits.
|
|
329
|
+
"""
|
|
330
|
+
return self._insert_run(
|
|
331
|
+
provider,
|
|
332
|
+
endpoint,
|
|
333
|
+
RunMode.WATERMARK,
|
|
334
|
+
_RunRange(
|
|
335
|
+
window_start_text=to_iso8601(window.start),
|
|
336
|
+
window_end_text=to_iso8601(window.end),
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def start_feed_run(
|
|
341
|
+
self, provider: Provider, endpoint: str, *, from_version: str
|
|
342
|
+
) -> int:
|
|
343
|
+
"""
|
|
344
|
+
Open a feed run resuming from ``from_version`` (``mode='feed'``).
|
|
345
|
+
|
|
346
|
+
``from_version`` is the feed arm's opaque start token, stored verbatim
|
|
347
|
+
(fleetpull never parses it) — or, on the seeded first run (which has
|
|
348
|
+
no token yet, while the table requires a non-null ``from_version`` on
|
|
349
|
+
a feed row), the runner's self-describing ``seed:<iso8601>`` marker
|
|
350
|
+
naming the ``search.fromDate`` the run seeded from. Never read back
|
|
351
|
+
by the program; the marker is run provenance, exactly like the rest
|
|
352
|
+
of the row. The run's end ``toVersion`` is recorded later by
|
|
353
|
+
:meth:`complete_run`.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
provider: The provider being fetched.
|
|
357
|
+
endpoint: The endpoint being fetched.
|
|
358
|
+
from_version: The feed arm's opaque start version, or the seeded
|
|
359
|
+
first run's ``seed:<iso8601>`` marker.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
The new run's ``run_id`` (the table's rowid alias).
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
RuntimeError: The INSERT returned no ``lastrowid``.
|
|
366
|
+
|
|
367
|
+
Side Effects:
|
|
368
|
+
Opens a connection, inserts one row, and commits.
|
|
369
|
+
"""
|
|
370
|
+
return self._insert_run(
|
|
371
|
+
provider, endpoint, RunMode.FEED, _RunRange(from_version=from_version)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def _insert_run(
|
|
375
|
+
self, provider: Provider, endpoint: str, mode: RunMode, run_range: _RunRange
|
|
376
|
+
) -> int:
|
|
377
|
+
"""
|
|
378
|
+
Insert one ``running`` row and return its ``run_id``.
|
|
379
|
+
|
|
380
|
+
The shared tail of the three ``start_*_run`` entry points: each builds the
|
|
381
|
+
``_RunRange`` its mode requires, then delegates the stamp, insert, and
|
|
382
|
+
``lastrowid`` check here. The mode-keyed range shape is the caller's
|
|
383
|
+
responsibility (and the CHECK's backstop); this helper persists what it is
|
|
384
|
+
given.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
RuntimeError: The INSERT returned no ``lastrowid`` — a SQLite contract
|
|
388
|
+
violation, surfaced loudly.
|
|
389
|
+
"""
|
|
390
|
+
started_at: str = to_iso8601(self._clock.now_utc())
|
|
391
|
+
with self._database.transaction() as connection:
|
|
392
|
+
run_id: int | None = connection.execute(
|
|
393
|
+
_INSERT_RUN_SQL,
|
|
394
|
+
(
|
|
395
|
+
provider.value,
|
|
396
|
+
endpoint,
|
|
397
|
+
RunStatus.RUNNING.value,
|
|
398
|
+
mode.value,
|
|
399
|
+
run_range.window_start_text,
|
|
400
|
+
run_range.window_end_text,
|
|
401
|
+
run_range.from_version,
|
|
402
|
+
started_at,
|
|
403
|
+
),
|
|
404
|
+
).lastrowid
|
|
405
|
+
if run_id is None:
|
|
406
|
+
raise RuntimeError('runs INSERT returned no lastrowid')
|
|
407
|
+
logger.debug(
|
|
408
|
+
'started run: run_id=%s provider=%s endpoint=%s mode=%s',
|
|
409
|
+
run_id,
|
|
410
|
+
provider.value,
|
|
411
|
+
endpoint,
|
|
412
|
+
mode.value,
|
|
413
|
+
)
|
|
414
|
+
return run_id
|
|
415
|
+
|
|
416
|
+
def complete_run(
|
|
417
|
+
self, run_id: int, *, row_count: int, to_version: str | None = None
|
|
418
|
+
) -> None:
|
|
419
|
+
"""
|
|
420
|
+
Close a run ``succeeded`` with its row count (and a feed run's ``toVersion``).
|
|
421
|
+
|
|
422
|
+
Reads the run's ``mode`` and refuses to cross it: snapshot and watermark
|
|
423
|
+
runs reject a ``to_version``, a feed run requires one. The mode-keyed range
|
|
424
|
+
CHECK is the structural backstop behind these guards.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
run_id: The run to close, from one of the ``start_*_run`` methods.
|
|
428
|
+
row_count: Rows fetched for the run; must be non-negative (zero is a
|
|
429
|
+
valid empty fetch).
|
|
430
|
+
to_version: The feed arm's end version — required for a feed run,
|
|
431
|
+
refused for a snapshot or watermark run.
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
ValueError: ``row_count`` is negative; ``run_id`` is unknown; a feed run
|
|
435
|
+
was not given a ``to_version``; or a snapshot/watermark run was —
|
|
436
|
+
all caller bugs, kept stdlib.
|
|
437
|
+
ConfigurationError: the stored ``mode`` is not a recognized ``RunMode``
|
|
438
|
+
— state-store corruption, the same stance as the cursor store.
|
|
439
|
+
RuntimeError: the stored ``mode`` came back non-text, violating the
|
|
440
|
+
STRICT ``TEXT`` schema contract.
|
|
441
|
+
|
|
442
|
+
Side Effects:
|
|
443
|
+
Opens a connection, reads the run's mode, updates the row, and commits.
|
|
444
|
+
"""
|
|
445
|
+
if row_count < 0:
|
|
446
|
+
raise ValueError(f'row_count must be non-negative, got {row_count}')
|
|
447
|
+
ended_at: str = to_iso8601(self._clock.now_utc())
|
|
448
|
+
with self._database.transaction() as connection:
|
|
449
|
+
mode: RunMode = _read_run_mode(connection, run_id)
|
|
450
|
+
match mode:
|
|
451
|
+
case RunMode.FEED:
|
|
452
|
+
if to_version is None:
|
|
453
|
+
raise ValueError(
|
|
454
|
+
'feed runs must record to_version on completion'
|
|
455
|
+
)
|
|
456
|
+
connection.execute(
|
|
457
|
+
_COMPLETE_FEED_RUN_SQL,
|
|
458
|
+
(
|
|
459
|
+
RunStatus.SUCCEEDED.value,
|
|
460
|
+
ended_at,
|
|
461
|
+
row_count,
|
|
462
|
+
to_version,
|
|
463
|
+
run_id,
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
case RunMode.SNAPSHOT | RunMode.WATERMARK:
|
|
467
|
+
if to_version is not None:
|
|
468
|
+
raise ValueError('to_version is only valid for feed runs')
|
|
469
|
+
connection.execute(
|
|
470
|
+
_COMPLETE_NONFEED_RUN_SQL,
|
|
471
|
+
(RunStatus.SUCCEEDED.value, ended_at, row_count, run_id),
|
|
472
|
+
)
|
|
473
|
+
logger.debug('completed run: run_id=%s row_count=%s', run_id, row_count)
|
|
474
|
+
|
|
475
|
+
def fail_run(self, run_id: int, *, error_detail: str) -> None:
|
|
476
|
+
"""
|
|
477
|
+
Close a run ``failed`` with an error detail.
|
|
478
|
+
|
|
479
|
+
Arm-agnostic: failing a run touches no range column, so no arm read is
|
|
480
|
+
needed — an UPDATE that matches no row is the unknown-``run_id`` signal.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
run_id: The run to close, from one of the ``start_*_run`` methods.
|
|
484
|
+
error_detail: Human-readable failure context recorded on the row.
|
|
485
|
+
|
|
486
|
+
Raises:
|
|
487
|
+
ValueError: ``run_id`` is unknown (the UPDATE matched no row) — a
|
|
488
|
+
caller bug, kept stdlib.
|
|
489
|
+
|
|
490
|
+
Side Effects:
|
|
491
|
+
Opens a connection, updates the row, and commits.
|
|
492
|
+
"""
|
|
493
|
+
ended_at: str = to_iso8601(self._clock.now_utc())
|
|
494
|
+
with self._database.transaction() as connection:
|
|
495
|
+
affected: int = connection.execute(
|
|
496
|
+
_FAIL_RUN_SQL,
|
|
497
|
+
(RunStatus.FAILED.value, ended_at, error_detail, run_id),
|
|
498
|
+
).rowcount
|
|
499
|
+
if affected == 0:
|
|
500
|
+
raise ValueError(f'no run with run_id {run_id}')
|
|
501
|
+
logger.debug('failed run: run_id=%s', run_id)
|
|
502
|
+
|
|
503
|
+
def coverage_frontier(self, provider: Provider, endpoint: str) -> datetime | None:
|
|
504
|
+
"""
|
|
505
|
+
Return the high-water mark of completed watermark coverage, or ``None``.
|
|
506
|
+
|
|
507
|
+
``max(window_end)`` over this endpoint's ``succeeded`` runs carrying a
|
|
508
|
+
window — the implementation of DESIGN §4/§5 resume arm (2). A backfill
|
|
509
|
+
chunk that completed empty is still ``succeeded``, so its window counts and
|
|
510
|
+
empty history is never re-scanned. The lexical ``max`` over the TEXT column
|
|
511
|
+
is the chronological one because ``to_iso8601`` emits a fixed-width,
|
|
512
|
+
zero-padded, ``Z``-suffixed form. Watermark-only by design: feed and
|
|
513
|
+
snapshot endpoints never reach this arm (a feed endpoint holds a committed
|
|
514
|
+
cursor; a snapshot has no resume).
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
provider: The provider whose coverage to read.
|
|
518
|
+
endpoint: The endpoint whose coverage to read.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
The latest covered ``window_end`` as a UTC datetime, or ``None`` when no
|
|
522
|
+
succeeded watermark run exists for this (provider, endpoint).
|
|
523
|
+
|
|
524
|
+
Raises:
|
|
525
|
+
ConfigurationError: A stored ``window_end`` is not parseable ISO-8601
|
|
526
|
+
UTC — state-store corruption, the same stance as the cursor store.
|
|
527
|
+
RuntimeError: The aggregated ``window_end`` came back non-text,
|
|
528
|
+
violating the STRICT ``TEXT`` schema contract.
|
|
529
|
+
|
|
530
|
+
Side Effects:
|
|
531
|
+
Opens a connection and reads one aggregate row.
|
|
532
|
+
"""
|
|
533
|
+
return _max_over_succeeded_runs(
|
|
534
|
+
self._database,
|
|
535
|
+
_COVERAGE_FRONTIER_SQL,
|
|
536
|
+
provider,
|
|
537
|
+
endpoint,
|
|
538
|
+
column='window_end',
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def last_success_at(self, provider: Provider, endpoint: str) -> datetime | None:
|
|
542
|
+
"""
|
|
543
|
+
Return when this endpoint last completed successfully, or ``None``.
|
|
544
|
+
|
|
545
|
+
``max(ended_at)`` over this endpoint's ``succeeded`` runs -- the wall-clock
|
|
546
|
+
completion time of its most recent success, across any sync mode (a snapshot
|
|
547
|
+
feeder carries ``ended_at`` but no window). The roster's staleness bound reads
|
|
548
|
+
this to decide whether a feeder re-list is due; it is not a resume arm, so
|
|
549
|
+
unlike ``coverage_frontier`` it does not filter on ``window_end``. The lexical
|
|
550
|
+
``max`` over the TEXT column is the chronological one because ``to_iso8601``
|
|
551
|
+
emits a fixed-width, zero-padded, ``Z``-suffixed form.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
provider: The provider whose last success to read.
|
|
555
|
+
endpoint: The endpoint whose last success to read.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
The latest ``ended_at`` of a succeeded run as a UTC datetime, or ``None``
|
|
559
|
+
when no succeeded run exists for this (provider, endpoint).
|
|
560
|
+
|
|
561
|
+
Raises:
|
|
562
|
+
ConfigurationError: A stored ``ended_at`` is not parseable ISO-8601 UTC --
|
|
563
|
+
state-store corruption, the same stance as ``coverage_frontier``.
|
|
564
|
+
RuntimeError: The aggregated ``ended_at`` came back non-text, violating the
|
|
565
|
+
STRICT ``TEXT`` schema contract.
|
|
566
|
+
|
|
567
|
+
Side Effects:
|
|
568
|
+
Opens a connection and reads one aggregate row.
|
|
569
|
+
"""
|
|
570
|
+
return _max_over_succeeded_runs(
|
|
571
|
+
self._database, _LAST_SUCCESS_SQL, provider, endpoint, column='ended_at'
|
|
572
|
+
)
|