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
fleetpull/api/sync.py
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
# src/fleetpull/api/sync.py
|
|
2
|
+
"""``Sync``: the config-driven public verb (DESIGN section 10).
|
|
3
|
+
|
|
4
|
+
Constructed on a path to a YAML config; ``run()`` returns ``None`` and
|
|
5
|
+
signals failure by raising. Where ``fetch`` is the in-memory convenience
|
|
6
|
+
verb, ``Sync`` is the pipeline: it composes the full machinery -- state
|
|
7
|
+
database, stores, registries, clients, the run executor -- from one
|
|
8
|
+
validated ``FleetpullConfig`` and runs every selected endpoint.
|
|
9
|
+
|
|
10
|
+
Construction is validation only: the config loads (``from_yaml``), every
|
|
11
|
+
selected endpoint name is checked against the public catalog (the
|
|
12
|
+
validation deliberately absent from the config tier, which sits below
|
|
13
|
+
the catalog), and zero enabled providers is a ``ConfigurationError`` --
|
|
14
|
+
a sync that syncs nothing is a configuration failure to surface, not a
|
|
15
|
+
no-op. Nothing global mutates and nothing but the config file is read.
|
|
16
|
+
|
|
17
|
+
``run()`` applies the logging section first, then composes and executes.
|
|
18
|
+
The concurrency ladder (DESIGN section 7, completed 2026-07-20):
|
|
19
|
+
providers run concurrently, one queue worker each; within a provider,
|
|
20
|
+
endpoints run in two concurrent stages -- the selected feeders
|
|
21
|
+
(endpoints sourcing a roster, derived via ``sourced_by``, never a
|
|
22
|
+
user-facing key; feeders never cross providers) first, a barrier, then
|
|
23
|
+
the consumers -- and within a fan-out endpoint, units and members run
|
|
24
|
+
concurrently on that provider's fetch pool. Queue order -- feeders then
|
|
25
|
+
consumers, config order within each -- is exactly the order the retired
|
|
26
|
+
serial queue executed in, surviving as the reporting contract, not an
|
|
27
|
+
execution order. Endpoints commit independently: one endpoint's
|
|
28
|
+
operational failure (the ``FleetpullError`` family) is recorded while
|
|
29
|
+
its queue continues; any other exception is a bug that stops the
|
|
30
|
+
queue's unstarted work (in-flight siblings finish and commit) and
|
|
31
|
+
re-raises -- the first by queue order within a provider, the first by
|
|
32
|
+
provider order across providers -- once every queue has joined. A run
|
|
33
|
+
with failures ends by raising ``SyncFailuresError`` carrying them in
|
|
34
|
+
queue order within each provider, providers in config order. Only the
|
|
35
|
+
selected set runs: an unselected feeder is never run on a consumer's
|
|
36
|
+
behalf -- roster freshness stays the refresh coordinator's job at
|
|
37
|
+
fan-out time (single-flight per roster key).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
import threading
|
|
42
|
+
from collections.abc import Sequence
|
|
43
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from pydantic import SecretStr
|
|
48
|
+
|
|
49
|
+
from fleetpull.api.auth_ingress import (
|
|
50
|
+
ProviderProfileContext,
|
|
51
|
+
build_provider_profile,
|
|
52
|
+
)
|
|
53
|
+
from fleetpull.api.catalog import available_endpoints
|
|
54
|
+
from fleetpull.api.identity import EndpointIdentity
|
|
55
|
+
from fleetpull.config import (
|
|
56
|
+
FleetpullConfig,
|
|
57
|
+
GeotabAuthConfig,
|
|
58
|
+
GeotabConfig,
|
|
59
|
+
MotiveConfig,
|
|
60
|
+
ProviderConfig,
|
|
61
|
+
SamsaraConfig,
|
|
62
|
+
default_provider_sections,
|
|
63
|
+
)
|
|
64
|
+
from fleetpull.endpoints import (
|
|
65
|
+
EndpointRegistry,
|
|
66
|
+
build_endpoint_registry,
|
|
67
|
+
build_roster_registry,
|
|
68
|
+
)
|
|
69
|
+
from fleetpull.endpoints.shared import EndpointDefinition
|
|
70
|
+
from fleetpull.exceptions import (
|
|
71
|
+
ConfigurationError,
|
|
72
|
+
EndpointFailure,
|
|
73
|
+
FleetpullError,
|
|
74
|
+
SyncFailuresError,
|
|
75
|
+
)
|
|
76
|
+
from fleetpull.logger import setup_logger
|
|
77
|
+
from fleetpull.model_contract import ResponseModel
|
|
78
|
+
from fleetpull.network.client import (
|
|
79
|
+
ClientRuntime,
|
|
80
|
+
ProviderClientRegistry,
|
|
81
|
+
ProviderProfile,
|
|
82
|
+
)
|
|
83
|
+
from fleetpull.network.limits import RateLimiterRegistry, rate_limits_from_configs
|
|
84
|
+
from fleetpull.orchestrator import (
|
|
85
|
+
EndpointRunner,
|
|
86
|
+
FetchPoolRegistry,
|
|
87
|
+
RosterMachinery,
|
|
88
|
+
RosterRefreshCoordinator,
|
|
89
|
+
RunStateAccess,
|
|
90
|
+
run_endpoint,
|
|
91
|
+
)
|
|
92
|
+
from fleetpull.roster import RosterRegistry
|
|
93
|
+
from fleetpull.state import (
|
|
94
|
+
CursorStore,
|
|
95
|
+
RosterStore,
|
|
96
|
+
RunLedger,
|
|
97
|
+
StateDatabase,
|
|
98
|
+
WorkUnitStore,
|
|
99
|
+
migrate_to_head,
|
|
100
|
+
)
|
|
101
|
+
from fleetpull.timing import SystemClock
|
|
102
|
+
from fleetpull.vocabulary import Provider
|
|
103
|
+
|
|
104
|
+
__all__: list[str] = ['Sync']
|
|
105
|
+
|
|
106
|
+
logger = logging.getLogger(__name__)
|
|
107
|
+
|
|
108
|
+
# The concrete provider-section union: new providers widen it as they port.
|
|
109
|
+
type _ProviderSection = MotiveConfig | GeotabConfig | SamsaraConfig
|
|
110
|
+
|
|
111
|
+
# The catalog as a lookup table: every public identity by registry key.
|
|
112
|
+
_CATALOG: dict[tuple[Provider, str], EndpointIdentity] = {
|
|
113
|
+
(identity.provider, identity.name): identity for identity in available_endpoints()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Sync:
|
|
118
|
+
"""
|
|
119
|
+
The config-driven sync run: one YAML file in, datasets and state out.
|
|
120
|
+
|
|
121
|
+
Construction validates; ``run()`` executes. One instance is one
|
|
122
|
+
validated configuration; ``run()`` may be called repeatedly (each
|
|
123
|
+
call is an independent, freshly composed run against the same
|
|
124
|
+
config).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, config_path: Path | str) -> None:
|
|
128
|
+
"""Load and validate the configuration; compose nothing yet.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
config_path: The YAML configuration file to load.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ConfigurationError: The config file is missing, unparseable,
|
|
135
|
+
or schema-invalid (from ``FleetpullConfig.from_yaml``);
|
|
136
|
+
a selected endpoint name is not in the public catalog
|
|
137
|
+
(naming the provider, the bad name, and the valid
|
|
138
|
+
names); or zero providers are enabled -- a sync that
|
|
139
|
+
syncs nothing is a configuration failure to surface.
|
|
140
|
+
|
|
141
|
+
Side Effects:
|
|
142
|
+
Reads the config file and the credential environment
|
|
143
|
+
variables (via ``from_yaml``, which also logs the
|
|
144
|
+
credential-without-endpoints WARNING). Nothing else.
|
|
145
|
+
"""
|
|
146
|
+
self._config = FleetpullConfig.from_yaml(config_path)
|
|
147
|
+
self._selection = _validated_selection(self._config)
|
|
148
|
+
|
|
149
|
+
def run(self) -> None:
|
|
150
|
+
"""Run every selected endpoint; raise if any failed.
|
|
151
|
+
|
|
152
|
+
Applies the logging section first (``setup_logger``), then
|
|
153
|
+
composes the run from the validated config: the state database
|
|
154
|
+
at the resolved ``state.database_path`` (migrated to head), the
|
|
155
|
+
stores, the discovered endpoint and roster registries, the
|
|
156
|
+
limiter registry from the precedence-resolved provider configs,
|
|
157
|
+
one transport client per enabled provider through the auth
|
|
158
|
+
ingress, and one fetch pool per enabled provider sized by its
|
|
159
|
+
``rate_limit.max_concurrency`` (the fan-out workers; shut down
|
|
160
|
+
when the run ends, success or failure). Endpoints run as one
|
|
161
|
+
staged queue per enabled provider, the queues concurrent (one
|
|
162
|
+
worker thread each): stage one runs the selected feeders
|
|
163
|
+
concurrently among themselves (derived from the roster
|
|
164
|
+
bindings; config order within each stage), the stage join is
|
|
165
|
+
the barrier no consumer crosses, and stage two runs the
|
|
166
|
+
consumers concurrently. Endpoints commit independently:
|
|
167
|
+
parquet and state land per endpoint as each finishes, so a
|
|
168
|
+
sibling's failure never rolls anything back, in its own queue
|
|
169
|
+
or another provider's. A non-``FleetpullError`` is a bug that
|
|
170
|
+
stops its queue's unstarted endpoints (in-flight siblings
|
|
171
|
+
finish and commit) while the other queues finish; after every
|
|
172
|
+
queue joins, the first bug -- by queue order within a
|
|
173
|
+
provider, provider order across providers -- re-raises.
|
|
174
|
+
|
|
175
|
+
The run narrates at INFO: a start line naming the enabled
|
|
176
|
+
providers, the validated selection, and the dataset root; a
|
|
177
|
+
finish line with the succeeded/failed endpoint counts and the
|
|
178
|
+
elapsed seconds (a ``monotonic_seconds`` delta on the run's own
|
|
179
|
+
clock -- the whole run's wall clock, the slowest queue rather
|
|
180
|
+
than the queues' sum -- emitted before any failure aggregate
|
|
181
|
+
raises).
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
None. Every selected endpoint ran and committed.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
SyncFailuresError: One or more endpoints failed with an
|
|
188
|
+
operational (``FleetpullError``-family) error; carries
|
|
189
|
+
the failures in queue order within each provider
|
|
190
|
+
(feeders then consumers, config order within each),
|
|
191
|
+
providers in config order. Successful siblings are
|
|
192
|
+
already committed.
|
|
193
|
+
FleetpullError: An operational failure outside any single
|
|
194
|
+
endpoint's run (e.g. a cold-start roster refresh
|
|
195
|
+
propagated by the entry) -- always one of the public
|
|
196
|
+
subclasses (``ConfigurationError``,
|
|
197
|
+
``AuthenticationError``, ``RetriesExhaustedError``,
|
|
198
|
+
``ProviderResponseError``).
|
|
199
|
+
|
|
200
|
+
Side Effects:
|
|
201
|
+
Configures the ``fleetpull`` logger from the config's
|
|
202
|
+
logging section; creates/migrates the SQLite state database;
|
|
203
|
+
fetches over the network (one queue worker thread per
|
|
204
|
+
enabled provider, one short-lived endpoint task pool per
|
|
205
|
+
non-empty stage, plus that provider's fan-out fetch
|
|
206
|
+
workers, all joined before this returns); writes parquet
|
|
207
|
+
under ``storage.dataset_root``; records runs, cursors, and
|
|
208
|
+
roster state.
|
|
209
|
+
|
|
210
|
+
Scope: retrieval, dtype coercion, and light structural
|
|
211
|
+
normalization only -- no cross-endpoint joins, no unified
|
|
212
|
+
schema, no assumed end use (DESIGN section 10).
|
|
213
|
+
"""
|
|
214
|
+
setup_logger(self._config.logging)
|
|
215
|
+
clock = SystemClock()
|
|
216
|
+
run_started = clock.monotonic_seconds()
|
|
217
|
+
logger.info(
|
|
218
|
+
'sync started: providers=[%s] endpoints=%d selection=[%s] dataset_root=%s',
|
|
219
|
+
', '.join(provider.value for provider, _ in self._enabled_providers()),
|
|
220
|
+
len(self._selection),
|
|
221
|
+
', '.join(f'{provider.value}.{name}' for provider, name in self._selection),
|
|
222
|
+
self._config.storage.dataset_root,
|
|
223
|
+
)
|
|
224
|
+
provider_configs = self._discovery_provider_configs()
|
|
225
|
+
endpoint_registry = build_endpoint_registry(provider_configs)
|
|
226
|
+
roster_registry = build_roster_registry()
|
|
227
|
+
stages_by_provider = _staged_by_provider(self._selection, roster_registry)
|
|
228
|
+
database = StateDatabase(_required_database_path(self._config))
|
|
229
|
+
database.initialize()
|
|
230
|
+
migrate_to_head(database)
|
|
231
|
+
cursor_store = CursorStore(database, clock)
|
|
232
|
+
run_ledger = RunLedger(database, clock)
|
|
233
|
+
roster_store = RosterStore(database)
|
|
234
|
+
unit_store = WorkUnitStore(database, clock)
|
|
235
|
+
limiter_registry = RateLimiterRegistry(
|
|
236
|
+
rate_limits_from_configs(provider_configs)
|
|
237
|
+
)
|
|
238
|
+
runtime = ClientRuntime(
|
|
239
|
+
http_config=self._config.http,
|
|
240
|
+
retry_config=self._config.retry,
|
|
241
|
+
limiter_registry=limiter_registry,
|
|
242
|
+
)
|
|
243
|
+
profile_context = ProviderProfileContext(
|
|
244
|
+
http_config=self._config.http,
|
|
245
|
+
limiter_registry=limiter_registry,
|
|
246
|
+
clock=clock,
|
|
247
|
+
)
|
|
248
|
+
fetch_workers = {
|
|
249
|
+
provider: config.rate_limit.max_concurrency
|
|
250
|
+
for provider, config in self._enabled_providers()
|
|
251
|
+
}
|
|
252
|
+
with (
|
|
253
|
+
ProviderClientRegistry(
|
|
254
|
+
self._provider_profiles(profile_context), runtime
|
|
255
|
+
) as clients,
|
|
256
|
+
FetchPoolRegistry(fetch_workers) as fetch_pools,
|
|
257
|
+
):
|
|
258
|
+
coordinator = RosterRefreshCoordinator(
|
|
259
|
+
endpoint_registry, roster_store, run_ledger, clients, clock
|
|
260
|
+
)
|
|
261
|
+
runner = EndpointRunner(
|
|
262
|
+
clients,
|
|
263
|
+
RunStateAccess(
|
|
264
|
+
recorder=run_ledger, cursors=cursor_store, units=unit_store
|
|
265
|
+
),
|
|
266
|
+
clock,
|
|
267
|
+
self._config,
|
|
268
|
+
)
|
|
269
|
+
work = _ProviderQueueWork(
|
|
270
|
+
registry=endpoint_registry,
|
|
271
|
+
runner=runner,
|
|
272
|
+
rosters=RosterMachinery(
|
|
273
|
+
registry=roster_registry,
|
|
274
|
+
refresher=coordinator,
|
|
275
|
+
members=roster_store,
|
|
276
|
+
),
|
|
277
|
+
fetch_pools=fetch_pools,
|
|
278
|
+
)
|
|
279
|
+
# One worker per enabled provider; exiting the block joins them
|
|
280
|
+
# all, so every queue has finished before anything raises. The
|
|
281
|
+
# futures dict is keyed in the fixed provider-config order (the
|
|
282
|
+
# enabled roll-call), which _collected_failures turns into the
|
|
283
|
+
# documented cross-provider order.
|
|
284
|
+
with ThreadPoolExecutor(
|
|
285
|
+
max_workers=len(stages_by_provider),
|
|
286
|
+
thread_name_prefix='fleetpull-sync',
|
|
287
|
+
) as queue_pool:
|
|
288
|
+
queue_futures = {
|
|
289
|
+
provider: queue_pool.submit(
|
|
290
|
+
_run_provider_queue,
|
|
291
|
+
provider,
|
|
292
|
+
stages_by_provider[provider],
|
|
293
|
+
work,
|
|
294
|
+
)
|
|
295
|
+
for provider, _ in self._enabled_providers()
|
|
296
|
+
}
|
|
297
|
+
failures = _collected_failures(queue_futures)
|
|
298
|
+
logger.info(
|
|
299
|
+
'sync finished: succeeded=%d failed=%d elapsed_seconds=%.1f',
|
|
300
|
+
len(self._selection) - len(failures),
|
|
301
|
+
len(failures),
|
|
302
|
+
clock.monotonic_seconds() - run_started,
|
|
303
|
+
)
|
|
304
|
+
if failures:
|
|
305
|
+
raise SyncFailuresError(tuple(failures))
|
|
306
|
+
|
|
307
|
+
def _enabled_providers(self) -> list[tuple[Provider, _ProviderSection]]:
|
|
308
|
+
"""The enabled providers, leaning on the validated enablement invariant.
|
|
309
|
+
|
|
310
|
+
A validated config guarantees a provider with endpoints has a
|
|
311
|
+
credential (``require_provider_credentials``), so enabled reduces to
|
|
312
|
+
"endpoints non-empty". Typed as the concrete section union in a fixed
|
|
313
|
+
provider order (Motive, GeoTab, Samsara -- ``ProvidersConfig`` field
|
|
314
|
+
order).
|
|
315
|
+
"""
|
|
316
|
+
return [
|
|
317
|
+
(provider, section)
|
|
318
|
+
for provider, section in _provider_sections(self._config)
|
|
319
|
+
if section is not None and section.endpoints
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
def _discovery_provider_configs(self) -> list[ProviderConfig]:
|
|
323
|
+
"""One config per provider package: the YAML section, or pure defaults.
|
|
324
|
+
|
|
325
|
+
The discovery walk builds every leaf it finds and requires a config
|
|
326
|
+
per provider package regardless of enablement, so a provider absent
|
|
327
|
+
from the YAML is represented by its default-constructed config
|
|
328
|
+
(``default_provider_sections`` -- the roll-call ``fetch`` consumes
|
|
329
|
+
too). Enabled providers contribute their YAML
|
|
330
|
+
instances, so the limiter budgets derived from this list are the
|
|
331
|
+
user's; a disabled provider's default config merely registers inert
|
|
332
|
+
scopes nothing spends from.
|
|
333
|
+
"""
|
|
334
|
+
defaults = default_provider_sections()
|
|
335
|
+
return [
|
|
336
|
+
section if section is not None else defaults[provider]
|
|
337
|
+
for provider, section in _provider_sections(self._config)
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
def _provider_profiles(
|
|
341
|
+
self, context: ProviderProfileContext
|
|
342
|
+
) -> dict[Provider, ProviderProfile]:
|
|
343
|
+
"""One client profile per enabled provider, through the auth ingress."""
|
|
344
|
+
profiles: dict[Provider, ProviderProfile] = {}
|
|
345
|
+
for provider, provider_config in self._enabled_providers():
|
|
346
|
+
identity = _CATALOG[(provider, provider_config.endpoints[0])]
|
|
347
|
+
credential = _required_credential(provider, provider_config)
|
|
348
|
+
profiles[provider] = build_provider_profile(identity, credential, context)
|
|
349
|
+
return profiles
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _provider_sections(
|
|
353
|
+
config: FleetpullConfig,
|
|
354
|
+
) -> list[tuple[Provider, _ProviderSection | None]]:
|
|
355
|
+
"""Every provider section, present or not, in the fixed provider order.
|
|
356
|
+
|
|
357
|
+
The single provider roll-call in this module: adding a provider means
|
|
358
|
+
extending this list and nothing else here.
|
|
359
|
+
"""
|
|
360
|
+
return [
|
|
361
|
+
(Provider.MOTIVE, config.providers.motive),
|
|
362
|
+
(Provider.GEOTAB, config.providers.geotab),
|
|
363
|
+
(Provider.SAMSARA, config.providers.samsara),
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _validated_selection(config: FleetpullConfig) -> list[tuple[Provider, str]]:
|
|
368
|
+
"""The selected endpoints, catalog-validated, in config order.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
config: The loaded configuration.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Every enabled provider's selected ``(provider, name)`` keys, in the
|
|
375
|
+
order the config lists them.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
ConfigurationError: A selected name is not in the public catalog, or
|
|
379
|
+
zero providers are enabled.
|
|
380
|
+
"""
|
|
381
|
+
selection: list[tuple[Provider, str]] = []
|
|
382
|
+
for provider, section in _provider_sections(config):
|
|
383
|
+
if section is None or not section.endpoints:
|
|
384
|
+
continue
|
|
385
|
+
for name in section.endpoints:
|
|
386
|
+
if (provider, name) not in _CATALOG:
|
|
387
|
+
valid_names = ', '.join(
|
|
388
|
+
sorted(
|
|
389
|
+
identity_name
|
|
390
|
+
for identity_provider, identity_name in _CATALOG
|
|
391
|
+
if identity_provider is provider
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
raise ConfigurationError(
|
|
395
|
+
'unknown endpoint name',
|
|
396
|
+
provider=provider.value,
|
|
397
|
+
endpoint=name,
|
|
398
|
+
detail=f'valid {provider.value} endpoints: {valid_names}',
|
|
399
|
+
)
|
|
400
|
+
selection.append((provider, name))
|
|
401
|
+
if not selection:
|
|
402
|
+
raise ConfigurationError(
|
|
403
|
+
'nothing to sync',
|
|
404
|
+
detail=(
|
|
405
|
+
'no provider is enabled (a provider is enabled when its '
|
|
406
|
+
'credential resolves and its endpoints list is non-empty)'
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
return selection
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@dataclass(frozen=True, slots=True)
|
|
413
|
+
class _ProviderStages:
|
|
414
|
+
"""One provider's staged endpoint queue: feeders, then consumers.
|
|
415
|
+
|
|
416
|
+
Queue order -- the order the failure contract reports in -- is the
|
|
417
|
+
concatenation, feeders then consumers, each in config order: exactly
|
|
418
|
+
the feeder-first order the retired serial queue executed in. Stage
|
|
419
|
+
membership is feeder-hood (the endpoint sources a roster), never
|
|
420
|
+
snapshot-hood, because the barrier between the stages exists for one
|
|
421
|
+
dependency only: a consumer's fan-out must not race the reconcile of
|
|
422
|
+
a roster a selected sibling feeder is about to refresh.
|
|
423
|
+
|
|
424
|
+
Attributes:
|
|
425
|
+
feeders: The endpoints sourcing any roster, in config order.
|
|
426
|
+
consumers: Every other selected endpoint, in config order.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
feeders: tuple[str, ...]
|
|
430
|
+
consumers: tuple[str, ...]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _staged_by_provider(
|
|
434
|
+
selection: Sequence[tuple[Provider, str]], roster_registry: RosterRegistry
|
|
435
|
+
) -> dict[Provider, _ProviderStages]:
|
|
436
|
+
"""Carve the selection into each provider's two-stage queue, order kept.
|
|
437
|
+
|
|
438
|
+
The pure staging step behind the intra-provider grain (DESIGN section
|
|
439
|
+
7): per provider, the selected endpoints split into feeders --
|
|
440
|
+
endpoints whose ``sourced_by`` is non-empty, derived from the roster
|
|
441
|
+
bindings and never a user-facing key -- and consumers (everything
|
|
442
|
+
else), each preserving config order. Single-level by construction: a
|
|
443
|
+
feeder is snapshot-mode (the reconcile guards enforce it) and never
|
|
444
|
+
consumes a roster itself, so feeder chains cannot exist today, and
|
|
445
|
+
feeders never cross providers. An unselected feeder is never enlisted
|
|
446
|
+
on a consumer's behalf -- roster freshness stays the refresh
|
|
447
|
+
coordinator's job at fan-out time.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
selection: The catalog-validated ``(provider, name)`` keys, in
|
|
451
|
+
config order.
|
|
452
|
+
roster_registry: The discovered roster catalog.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
Each selected provider's stages, keyed in first-appearance
|
|
456
|
+
(provider-config) order.
|
|
457
|
+
"""
|
|
458
|
+
feeders: dict[Provider, list[str]] = {}
|
|
459
|
+
consumers: dict[Provider, list[str]] = {}
|
|
460
|
+
for provider, name in selection:
|
|
461
|
+
feeders.setdefault(provider, [])
|
|
462
|
+
consumers.setdefault(provider, [])
|
|
463
|
+
stage = feeders if roster_registry.sourced_by(provider, name) else consumers
|
|
464
|
+
stage[provider].append(name)
|
|
465
|
+
return {
|
|
466
|
+
provider: _ProviderStages(
|
|
467
|
+
feeders=tuple(feeders[provider]), consumers=tuple(consumers[provider])
|
|
468
|
+
)
|
|
469
|
+
for provider in feeders
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@dataclass(frozen=True, slots=True)
|
|
474
|
+
class _ProviderQueueWork:
|
|
475
|
+
"""The shared collaborators every provider queue runs its endpoints through.
|
|
476
|
+
|
|
477
|
+
One instance serves every queue worker and endpoint task; the four ride
|
|
478
|
+
as one parameter because they always travel together into
|
|
479
|
+
``run_endpoint`` (the bundle rule). Safe to share by construction: the
|
|
480
|
+
registry is an immutable catalog, the runner keeps all per-run state
|
|
481
|
+
local, the roster machinery's state lives in connection-per-operation
|
|
482
|
+
stores (rosters are provider-scoped, so one queue's rosters are never
|
|
483
|
+
another's, and concurrent consumers of one roster serialize through the
|
|
484
|
+
refresh coordinator's per-key single-flight lock), and the fetch pools
|
|
485
|
+
are per-provider.
|
|
486
|
+
|
|
487
|
+
Attributes:
|
|
488
|
+
registry: The discovered endpoint catalog (definition lookup).
|
|
489
|
+
runner: The run executor, shared by every queue.
|
|
490
|
+
rosters: The roster catalog, policy coordinator, and member read.
|
|
491
|
+
fetch_pools: The per-provider fetch pools.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
registry: EndpointRegistry
|
|
495
|
+
runner: EndpointRunner
|
|
496
|
+
rosters: RosterMachinery
|
|
497
|
+
fetch_pools: FetchPoolRegistry
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@dataclass(frozen=True, slots=True)
|
|
501
|
+
class _SkippedEndpoint:
|
|
502
|
+
"""The skip sentinel an endpoint task returns instead of running.
|
|
503
|
+
|
|
504
|
+
Returned when the stop event was already set at task start: a sibling's
|
|
505
|
+
bug stopped the queue, so unstarted work is skipped -- never run, never
|
|
506
|
+
failed. Distinct from ``None`` (a clean run) so the task's contract
|
|
507
|
+
states the skip explicitly rather than conflating it with success.
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class _StagedQueueRun:
|
|
512
|
+
"""The shared state one provider queue's staged endpoint tasks race on.
|
|
513
|
+
|
|
514
|
+
Exactly one thing is cross-thread: the stop event a bug sets before it
|
|
515
|
+
escapes through its future, checked by every task before it runs --
|
|
516
|
+
in-flight siblings finish and commit; only unstarted work is skipped
|
|
517
|
+
(the ``_CrewDrive`` stop semantics). Operational failures never set it:
|
|
518
|
+
the queue continues, exactly as the serial queue did. Everything else
|
|
519
|
+
stays local -- each stage's futures are drained in submission (queue)
|
|
520
|
+
order after the stage's pool joins, so failures collect in queue order
|
|
521
|
+
and the first bug re-raises deterministically by queue order, never by
|
|
522
|
+
completion timing, with no locks and no re-sort. The class exists so
|
|
523
|
+
the task function reads as the endpoint run it is, with the
|
|
524
|
+
synchronization named rather than threaded through arguments.
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
def __init__(self, provider: Provider, work: _ProviderQueueWork) -> None:
|
|
528
|
+
self._provider = provider
|
|
529
|
+
self._work = work
|
|
530
|
+
self._stop_running = threading.Event()
|
|
531
|
+
|
|
532
|
+
def run_stage(self, stage_names: Sequence[str]) -> list[EndpointFailure]:
|
|
533
|
+
"""Run one stage's endpoints concurrently; join; report in queue order.
|
|
534
|
+
|
|
535
|
+
An empty stage spawns nothing; a single-endpoint stage degenerates
|
|
536
|
+
to the serial path's behavior on one short-lived task thread. The
|
|
537
|
+
``with`` block joins every task before the drain, so the barrier
|
|
538
|
+
between the feeder stage and the consumer stage is this join.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
stage_names: The stage's endpoint names, in queue order.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
The stage's operational failures, in queue order.
|
|
545
|
+
|
|
546
|
+
Raises:
|
|
547
|
+
Exception: The first bug by queue order, re-raised from its
|
|
548
|
+
future after the join -- never a ``FleetpullError`` from
|
|
549
|
+
``run_endpoint`` (those are collected, not raised).
|
|
550
|
+
|
|
551
|
+
Side Effects:
|
|
552
|
+
Runs every endpoint not skipped by the stop event (network
|
|
553
|
+
fetches, parquet writes, state commits); logs each operational
|
|
554
|
+
failure at ERROR with its traceback.
|
|
555
|
+
"""
|
|
556
|
+
if not stage_names:
|
|
557
|
+
return []
|
|
558
|
+
with ThreadPoolExecutor(
|
|
559
|
+
max_workers=len(stage_names),
|
|
560
|
+
thread_name_prefix=f'fleetpull-sync-{self._provider.value}',
|
|
561
|
+
) as stage_pool:
|
|
562
|
+
endpoint_futures = [
|
|
563
|
+
stage_pool.submit(
|
|
564
|
+
self._run_endpoint_task,
|
|
565
|
+
self._work.registry.get(self._provider, name),
|
|
566
|
+
)
|
|
567
|
+
for name in stage_names
|
|
568
|
+
]
|
|
569
|
+
return [
|
|
570
|
+
outcome
|
|
571
|
+
for outcome in (future.result() for future in endpoint_futures)
|
|
572
|
+
if isinstance(outcome, EndpointFailure)
|
|
573
|
+
]
|
|
574
|
+
|
|
575
|
+
def _run_endpoint_task(
|
|
576
|
+
self, definition: EndpointDefinition[ResponseModel]
|
|
577
|
+
) -> EndpointFailure | _SkippedEndpoint | None:
|
|
578
|
+
"""Run one resolved endpoint, unless a sibling bug stopped the queue.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
definition: The endpoint to run, resolved at submission.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
``None`` on a clean run, the ``EndpointFailure`` on an
|
|
585
|
+
operational failure, or the skip sentinel when the stop event
|
|
586
|
+
was set before this task ran.
|
|
587
|
+
|
|
588
|
+
Side Effects:
|
|
589
|
+
Renames the current thread to
|
|
590
|
+
``fleetpull-sync-<provider>-<endpoint>`` for log attribution;
|
|
591
|
+
whatever ``run_endpoint`` performs; sets the stop event before
|
|
592
|
+
a bug escapes through this task's future.
|
|
593
|
+
"""
|
|
594
|
+
threading.current_thread().name = (
|
|
595
|
+
f'fleetpull-sync-{self._provider.value}-{definition.name}'
|
|
596
|
+
)
|
|
597
|
+
if self._stop_running.is_set():
|
|
598
|
+
logger.debug(
|
|
599
|
+
'endpoint skipped: provider=%s endpoint=%s '
|
|
600
|
+
'(a sibling bug stopped the queue)',
|
|
601
|
+
self._provider.value,
|
|
602
|
+
definition.name,
|
|
603
|
+
)
|
|
604
|
+
return _SkippedEndpoint()
|
|
605
|
+
try:
|
|
606
|
+
run_endpoint(
|
|
607
|
+
definition,
|
|
608
|
+
self._work.runner,
|
|
609
|
+
self._work.rosters,
|
|
610
|
+
self._work.fetch_pools,
|
|
611
|
+
)
|
|
612
|
+
except FleetpullError as failure:
|
|
613
|
+
logger.exception(
|
|
614
|
+
'endpoint failed: provider=%s endpoint=%s',
|
|
615
|
+
self._provider.value,
|
|
616
|
+
definition.name,
|
|
617
|
+
)
|
|
618
|
+
return EndpointFailure(self._provider.value, definition.name, failure)
|
|
619
|
+
except Exception:
|
|
620
|
+
# Stop BEFORE the bug escapes, so a sibling's next stop check
|
|
621
|
+
# sees it; the bug itself re-raises at the queue-order drain.
|
|
622
|
+
self._stop_running.set()
|
|
623
|
+
raise
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _run_provider_queue(
|
|
628
|
+
provider: Provider, stages: _ProviderStages, work: _ProviderQueueWork
|
|
629
|
+
) -> list[EndpointFailure]:
|
|
630
|
+
"""Run one provider's staged queue, collecting its operational failures.
|
|
631
|
+
|
|
632
|
+
The per-provider queue worker: stage one runs the selected feeders
|
|
633
|
+
concurrently among themselves (they reconcile distinct roster keys, so
|
|
634
|
+
they cannot interfere; the set is usually zero or one), the stage join
|
|
635
|
+
is the barrier no consumer crosses before every feeder has finished,
|
|
636
|
+
and stage two runs the consumers concurrently. A ``FleetpullError``
|
|
637
|
+
records an ``EndpointFailure`` and the queue continues; any other
|
|
638
|
+
exception is a bug that stops the queue's unstarted endpoints and
|
|
639
|
+
propagates through this worker's future -- a stage-one bug skips stage
|
|
640
|
+
two entirely -- to re-raise after every queue has joined. The thread
|
|
641
|
+
is renamed to the provider first, so any stack trace attributes
|
|
642
|
+
cleanly.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
provider: The queue's provider.
|
|
646
|
+
stages: The provider's staged endpoints, in queue order.
|
|
647
|
+
work: The shared collaborators the endpoints run through.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
The queue's failures, in queue order (feeders then consumers,
|
|
651
|
+
config order within each).
|
|
652
|
+
|
|
653
|
+
Side Effects:
|
|
654
|
+
Renames the current thread to ``fleetpull-sync-<provider>``;
|
|
655
|
+
spawns one short-lived endpoint task pool per non-empty stage;
|
|
656
|
+
runs every endpoint (network fetches, parquet writes, state
|
|
657
|
+
commits); logs each operational failure at ERROR with its
|
|
658
|
+
traceback.
|
|
659
|
+
"""
|
|
660
|
+
threading.current_thread().name = f'fleetpull-sync-{provider.value}'
|
|
661
|
+
queue_run = _StagedQueueRun(provider, work)
|
|
662
|
+
failures = queue_run.run_stage(stages.feeders)
|
|
663
|
+
failures.extend(queue_run.run_stage(stages.consumers))
|
|
664
|
+
return failures
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _collected_failures(
|
|
668
|
+
queue_futures: dict[Provider, Future[list[EndpointFailure]]],
|
|
669
|
+
) -> list[EndpointFailure]:
|
|
670
|
+
"""Fold the joined queues into the documented two-level failure order.
|
|
671
|
+
|
|
672
|
+
Iterates the futures in their key order -- provider-config order, the
|
|
673
|
+
caller's contract -- flattening each queue's queue-order failures, so
|
|
674
|
+
the aggregate reads queue order within a provider, provider-config
|
|
675
|
+
order across providers. A queue that died on a bug re-raises it here
|
|
676
|
+
(``Future.result``), and the iteration order makes the first bug by
|
|
677
|
+
provider order win deterministically, never by completion timing. Every
|
|
678
|
+
future is already done (the pool's ``with`` block joined the workers),
|
|
679
|
+
so nothing here blocks.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
queue_futures: One joined future per provider queue, keyed in
|
|
683
|
+
provider-config order.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
Every queue's failures, in the two-level order.
|
|
687
|
+
|
|
688
|
+
Raises:
|
|
689
|
+
Exception: The first queue's bug in provider order, re-raised
|
|
690
|
+
unchanged -- never a ``FleetpullError`` (those are collected,
|
|
691
|
+
not raised, by the workers).
|
|
692
|
+
"""
|
|
693
|
+
failures: list[EndpointFailure] = []
|
|
694
|
+
for queue_future in queue_futures.values():
|
|
695
|
+
failures.extend(queue_future.result())
|
|
696
|
+
return failures
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _required_database_path(config: FleetpullConfig) -> Path:
|
|
700
|
+
"""The resolved state database path; a missing one is a wiring bug.
|
|
701
|
+
|
|
702
|
+
``from_yaml`` always resolves it (the root invariant), and ``Sync``
|
|
703
|
+
only loads via ``from_yaml`` -- this narrows the field's optional
|
|
704
|
+
type and trips loudly if that invariant is ever bypassed.
|
|
705
|
+
"""
|
|
706
|
+
database_path = config.state.database_path
|
|
707
|
+
if database_path is None:
|
|
708
|
+
raise ConfigurationError(
|
|
709
|
+
'state database path unresolved',
|
|
710
|
+
detail='state.database_path is unset; load the config via from_yaml',
|
|
711
|
+
)
|
|
712
|
+
return database_path
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _required_credential(
|
|
716
|
+
provider: Provider, config: _ProviderSection
|
|
717
|
+
) -> SecretStr | GeotabAuthConfig:
|
|
718
|
+
"""Narrow an enabled provider's credential; absence is a wiring bug.
|
|
719
|
+
|
|
720
|
+
The enablement validator guarantees it for any validated config; this
|
|
721
|
+
trips loudly if that invariant is ever bypassed by direct construction.
|
|
722
|
+
The section's ``credential`` property already carries the ingress
|
|
723
|
+
``AuthInput`` shape for its provider: the ``SecretStr`` key for the
|
|
724
|
+
static-key providers (Motive, Samsara), GeoTab's four-field credential
|
|
725
|
+
whole (its password's ``SecretStr`` passes straight through -- no
|
|
726
|
+
unwrap/rewrap).
|
|
727
|
+
"""
|
|
728
|
+
credential = config.credential
|
|
729
|
+
if credential is None:
|
|
730
|
+
raise ConfigurationError(
|
|
731
|
+
'provider credential missing',
|
|
732
|
+
provider=provider.value,
|
|
733
|
+
detail='an enabled provider must carry a credential (validated at load)',
|
|
734
|
+
)
|
|
735
|
+
return credential
|