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,266 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/feed_drive.py
|
|
2
|
+
"""The feed drive: the run executor's per-page version-token arm.
|
|
3
|
+
|
|
4
|
+
``FeedDrive`` drives one feed endpoint's version-token stream (DESIGN
|
|
5
|
+
sections 4/14, built 2026-07-21): resume is the stored ``FeedToken``, or a
|
|
6
|
+
``FeedSeed`` at the sync-wide cold-start anchor when none is stored (the
|
|
7
|
+
seed rides ONLY the tokenless first run -- I4); each page then commits
|
|
8
|
+
independently in the per-page crash order parquet BEFORE token (I1/I2) --
|
|
9
|
+
the append writer lands the page's rows durably, then the page's
|
|
10
|
+
``toVersion`` commits through the store's kind-guarded feed write -- so a
|
|
11
|
+
crash between the two refetches exactly one page on the next run and its
|
|
12
|
+
rows land again as new appended rows, harmless under the stored-as-emitted
|
|
13
|
+
contract. The drive consumes the page stream directly
|
|
14
|
+
(``stream_processed_batches`` deliberately drops ``durable_progress`` and
|
|
15
|
+
stays the non-feed pipe).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
|
|
21
|
+
from fleetpull.endpoints.shared import EndpointDefinition
|
|
22
|
+
from fleetpull.exceptions import ConfigurationError
|
|
23
|
+
from fleetpull.incremental import FeedResume, FeedSeed, FeedToken
|
|
24
|
+
from fleetpull.model_contract import ResponseModel
|
|
25
|
+
from fleetpull.network.client import FetchedPage
|
|
26
|
+
from fleetpull.orchestrator.batch import process_batch
|
|
27
|
+
from fleetpull.orchestrator.drivers import RequestDriver
|
|
28
|
+
from fleetpull.orchestrator.outcome import Executed, RunOutcome
|
|
29
|
+
from fleetpull.orchestrator.recording import recorded_run
|
|
30
|
+
from fleetpull.orchestrator.resume import resolve_feed_resume
|
|
31
|
+
from fleetpull.orchestrator.spine import RunnerSpine
|
|
32
|
+
from fleetpull.orchestrator.streaming import BatchObserver
|
|
33
|
+
from fleetpull.storage import DatasetWriter
|
|
34
|
+
from fleetpull.timing import to_iso8601
|
|
35
|
+
from fleetpull.vocabulary import Provider
|
|
36
|
+
|
|
37
|
+
__all__: list[str] = ['FeedDrive']
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _feed_resume_label(resume: FeedResume) -> str:
|
|
43
|
+
"""The ledger's ``from_version`` text for a feed run's resume value.
|
|
44
|
+
|
|
45
|
+
A resumed run records the token verbatim; a seeded run records the
|
|
46
|
+
self-describing ``seed:<iso8601>`` marker (the ``runs`` table requires a
|
|
47
|
+
non-null ``from_version`` on a feed row, and the seed date IS what the
|
|
48
|
+
run resumed from -- the convention is recorded on
|
|
49
|
+
``RunLedger.start_feed_run``).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
resume: The run's resolved feed resume value.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The token, or ``seed:<iso8601 start>`` for a seeded run.
|
|
56
|
+
"""
|
|
57
|
+
match resume:
|
|
58
|
+
case FeedToken(from_version=from_version):
|
|
59
|
+
return from_version
|
|
60
|
+
case FeedSeed(start=start):
|
|
61
|
+
return f'seed:{to_iso8601(start)}'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _log_feed_resume(provider: Provider, endpoint: str, resume: FeedResume) -> None:
|
|
65
|
+
"""Narrate a feed run's resume point at INFO: seeded or resumed.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
provider: The endpoint's provider.
|
|
69
|
+
endpoint: The endpoint name.
|
|
70
|
+
resume: The run's resolved feed resume value.
|
|
71
|
+
|
|
72
|
+
Side Effects:
|
|
73
|
+
Emits one INFO line.
|
|
74
|
+
"""
|
|
75
|
+
match resume:
|
|
76
|
+
case FeedToken(from_version=from_version):
|
|
77
|
+
logger.info(
|
|
78
|
+
'feed run resumed: provider=%s endpoint=%s from_version=%s',
|
|
79
|
+
provider.value,
|
|
80
|
+
endpoint,
|
|
81
|
+
from_version,
|
|
82
|
+
)
|
|
83
|
+
case FeedSeed(start=start):
|
|
84
|
+
logger.info(
|
|
85
|
+
'feed run seeded: provider=%s endpoint=%s from_date=%s',
|
|
86
|
+
provider.value,
|
|
87
|
+
endpoint,
|
|
88
|
+
to_iso8601(start),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FeedDrive:
|
|
93
|
+
"""Drives one feed endpoint's version-token stream, page by page.
|
|
94
|
+
|
|
95
|
+
Constructed once by ``EndpointRunner.__init__`` from the shared spine;
|
|
96
|
+
``run`` takes the endpoint, its request driver, and the optional
|
|
97
|
+
per-frame observer.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, spine: RunnerSpine) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Args:
|
|
103
|
+
spine: The shared drive kit -- collaborators plus the runner's
|
|
104
|
+
writer-factory and projection seams.
|
|
105
|
+
"""
|
|
106
|
+
self._spine = spine
|
|
107
|
+
|
|
108
|
+
def run(
|
|
109
|
+
self,
|
|
110
|
+
definition: EndpointDefinition[ResponseModel],
|
|
111
|
+
driver: RequestDriver,
|
|
112
|
+
observer: BatchObserver | None,
|
|
113
|
+
) -> RunOutcome:
|
|
114
|
+
"""Run the feed arm: drive the version-token stream, page by page.
|
|
115
|
+
|
|
116
|
+
Resume is the stored ``FeedToken`` used directly, or a ``FeedSeed``
|
|
117
|
+
at the sync-wide cold-start anchor when no token is stored -- the
|
|
118
|
+
seed rides ONLY the tokenless first run (I4; ``resolve_feed_resume``
|
|
119
|
+
makes it structural). The client is resolved before the run is
|
|
120
|
+
opened, so an unconfigured provider opens no dangling run. Each page
|
|
121
|
+
then commits independently through ``_consume_feed_pages`` in the
|
|
122
|
+
per-page crash order (parquet before token -- I1/I2); the ledger row
|
|
123
|
+
closes with the run's total row count and final ``toVersion`` after
|
|
124
|
+
the stream drains. A crash mid-stream leaves the run ``running``
|
|
125
|
+
(diagnostic only, the §5 stance) with every completed page's parquet
|
|
126
|
+
and token already committed -- the next run resumes from the last
|
|
127
|
+
committed token and refetches exactly one page, whose rows append
|
|
128
|
+
again as duplicates the stored-as-emitted contract absorbs (§4).
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
definition: The feed endpoint to run.
|
|
132
|
+
driver: The request driver supplying the run's pages (a
|
|
133
|
+
``SingleRequestDriver`` -- feeds are single-chain).
|
|
134
|
+
observer: The optional per-frame hook, handed each
|
|
135
|
+
post-validation frame as the run streams.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
``Executed`` with the fetched-row count and the append report.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ConfigurationError: A watermark cursor is stored for this feed
|
|
142
|
+
endpoint (cross-mode corruption, from ``resolve_feed_resume``
|
|
143
|
+
-- raised before any run is opened), or a page carried no
|
|
144
|
+
durable progress (a non-feed decoder wired to a feed
|
|
145
|
+
endpoint).
|
|
146
|
+
FleetpullError: A fetch, validation, write, or completion failure
|
|
147
|
+
-- the run is recorded failed and the original error
|
|
148
|
+
re-raised; pages committed before it stand.
|
|
149
|
+
"""
|
|
150
|
+
provider = definition.provider
|
|
151
|
+
name = definition.name
|
|
152
|
+
state = self._spine.state
|
|
153
|
+
client = self._spine.clients.client_for(provider)
|
|
154
|
+
resume = resolve_feed_resume(
|
|
155
|
+
state.cursors.get_cursor(provider, name),
|
|
156
|
+
self._spine.sync.default_start_datetime,
|
|
157
|
+
provider,
|
|
158
|
+
name,
|
|
159
|
+
)
|
|
160
|
+
_log_feed_resume(provider, name, resume)
|
|
161
|
+
run_id = state.recorder.start_feed_run(
|
|
162
|
+
provider, name, from_version=_feed_resume_label(resume)
|
|
163
|
+
)
|
|
164
|
+
with recorded_run(state.recorder, run_id):
|
|
165
|
+
writer = self._spine.make_writer(definition)
|
|
166
|
+
pages = driver.record_batches(definition, client, resume)
|
|
167
|
+
records_fetched, page_count, last_token = self._consume_feed_pages(
|
|
168
|
+
definition, pages, writer, observer
|
|
169
|
+
)
|
|
170
|
+
write = writer.finalize()
|
|
171
|
+
state.recorder.complete_run(
|
|
172
|
+
run_id, row_count=records_fetched, to_version=last_token
|
|
173
|
+
)
|
|
174
|
+
logger.info(
|
|
175
|
+
'feed complete: provider=%s endpoint=%s pages=%d records=%d to_version=%s',
|
|
176
|
+
provider.value,
|
|
177
|
+
name,
|
|
178
|
+
page_count,
|
|
179
|
+
records_fetched,
|
|
180
|
+
last_token,
|
|
181
|
+
)
|
|
182
|
+
outcome = Executed(records_fetched=records_fetched, write=write)
|
|
183
|
+
self._spine.projection.project(definition, outcome, window=None)
|
|
184
|
+
return outcome
|
|
185
|
+
|
|
186
|
+
def _consume_feed_pages(
|
|
187
|
+
self,
|
|
188
|
+
definition: EndpointDefinition[ResponseModel],
|
|
189
|
+
pages: Iterator[FetchedPage],
|
|
190
|
+
writer: DatasetWriter,
|
|
191
|
+
observer: BatchObserver | None,
|
|
192
|
+
) -> tuple[int, int, str]:
|
|
193
|
+
"""Consume the feed stream: per page, parquet BEFORE token (I1/I2).
|
|
194
|
+
|
|
195
|
+
The per-page transaction (DESIGN section 14): validate and frame the
|
|
196
|
+
page (``process_batch`` with no window context -- the feed has no
|
|
197
|
+
window, no future-event guard, no fold; whatever the stream emits is
|
|
198
|
+
stored), hand the frame to the observer where one rides, append it
|
|
199
|
+
durably (the feed writer's ``write`` is durable on return -- the
|
|
200
|
+
append-log cell's contract), and only THEN commit the page's
|
|
201
|
+
``toVersion``. The token therefore never moves past unwritten data:
|
|
202
|
+
a crash between the two loses only the token, and the next run
|
|
203
|
+
refetches that one page. An empty page (the at-head terminal)
|
|
204
|
+
appends nothing and re-commits its unchanged token -- the feed
|
|
205
|
+
always has a cursor to write (section 5).
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
definition: The feed endpoint being run.
|
|
209
|
+
pages: The driver's fetched-page stream, ready to consume.
|
|
210
|
+
writer: The run's append writer, handed each frame in order.
|
|
211
|
+
observer: The optional per-frame hook.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The fetched-row count, the page count, and the final committed
|
|
215
|
+
``toVersion``.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ConfigurationError: A page carried no ``durable_progress`` -- a
|
|
219
|
+
non-feed decoder is wired to a feed endpoint, a construction
|
|
220
|
+
bug surfaced before the page's rows are written.
|
|
221
|
+
RuntimeError: The stream yielded no pages, violating the
|
|
222
|
+
client's at-least-one-page contract.
|
|
223
|
+
|
|
224
|
+
Side Effects:
|
|
225
|
+
Appends part files and commits the feed cursor, page by page.
|
|
226
|
+
"""
|
|
227
|
+
records_fetched = 0
|
|
228
|
+
page_count = 0
|
|
229
|
+
last_token: str | None = None
|
|
230
|
+
for page in pages:
|
|
231
|
+
token = page.durable_progress
|
|
232
|
+
if token is None:
|
|
233
|
+
raise ConfigurationError(
|
|
234
|
+
'feed page carries no durable progress',
|
|
235
|
+
provider=definition.provider.value,
|
|
236
|
+
endpoint=definition.name,
|
|
237
|
+
detail=(
|
|
238
|
+
'the endpoint declares FeedMode but its page decoder '
|
|
239
|
+
'yielded no resume token -- a non-feed decoder is wired '
|
|
240
|
+
'to a feed endpoint'
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
processed = process_batch(page.records, definition, None)
|
|
244
|
+
if observer is not None:
|
|
245
|
+
observer(processed.frame)
|
|
246
|
+
writer.write(processed.frame)
|
|
247
|
+
self._spine.state.cursors.commit_feed_token(
|
|
248
|
+
definition.provider, definition.name, token
|
|
249
|
+
)
|
|
250
|
+
records_fetched += processed.frame.height
|
|
251
|
+
page_count += 1
|
|
252
|
+
last_token = token
|
|
253
|
+
logger.debug(
|
|
254
|
+
'feed page appended: provider=%s endpoint=%s page=%d records=%d '
|
|
255
|
+
'to_version=%s',
|
|
256
|
+
definition.provider.value,
|
|
257
|
+
definition.name,
|
|
258
|
+
page_count,
|
|
259
|
+
processed.frame.height,
|
|
260
|
+
token,
|
|
261
|
+
)
|
|
262
|
+
if last_token is None:
|
|
263
|
+
raise RuntimeError(
|
|
264
|
+
'feed drive yielded no pages -- fetch_pages always drives at least one'
|
|
265
|
+
)
|
|
266
|
+
return records_fetched, page_count, last_token
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/metadata_projection.py
|
|
2
|
+
"""The ``metadata.json`` projection: a committed run's facts, humanly readable.
|
|
3
|
+
|
|
4
|
+
``MetadataProjection`` writes the per-endpoint ``metadata.json`` snapshot
|
|
5
|
+
after a successful run fully commits (DESIGN §3) -- post-commit and
|
|
6
|
+
best-effort, never part of the run's transaction: the outcome's counts, the
|
|
7
|
+
run's resolved window, and a cursor read-back from the store flatten into a
|
|
8
|
+
``MetadataSnapshot`` the storage face renders and atomically writes. The
|
|
9
|
+
program never reads the file back (SQLite stays the single source of truth,
|
|
10
|
+
§5). ``sync_mode_label`` also serves the runner's endpoint-start narration,
|
|
11
|
+
so renaming a label changes both surfaces together.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from fleetpull.endpoints.shared import (
|
|
18
|
+
EndpointDefinition,
|
|
19
|
+
FeedMode,
|
|
20
|
+
SnapshotMode,
|
|
21
|
+
SyncMode,
|
|
22
|
+
WatermarkMode,
|
|
23
|
+
)
|
|
24
|
+
from fleetpull.incremental import (
|
|
25
|
+
DateWatermark,
|
|
26
|
+
DateWindow,
|
|
27
|
+
FeedToken,
|
|
28
|
+
IncrementalCursor,
|
|
29
|
+
)
|
|
30
|
+
from fleetpull.model_contract import ResponseModel
|
|
31
|
+
from fleetpull.orchestrator.outcome import Executed
|
|
32
|
+
from fleetpull.paths import PathInput, endpoint_directory
|
|
33
|
+
from fleetpull.state import CursorKind
|
|
34
|
+
from fleetpull.storage import (
|
|
35
|
+
MetadataSnapshot,
|
|
36
|
+
render_metadata_json,
|
|
37
|
+
write_metadata_json,
|
|
38
|
+
)
|
|
39
|
+
from fleetpull.timing import Clock, to_iso8601
|
|
40
|
+
from fleetpull.vocabulary import Provider
|
|
41
|
+
|
|
42
|
+
__all__: list[str] = ['CursorReader', 'MetadataProjection', 'sync_mode_label']
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CursorReader(Protocol):
|
|
48
|
+
"""The one-method cursor read-back the projection needs."""
|
|
49
|
+
|
|
50
|
+
def get_cursor(self, provider: Provider, endpoint: str) -> IncrementalCursor | None:
|
|
51
|
+
"""Return the persisted cursor for a (provider, endpoint), or None."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sync_mode_label(sync_mode: SyncMode) -> str:
|
|
56
|
+
"""The sync mode's human-readable label.
|
|
57
|
+
|
|
58
|
+
Shared by the ``metadata.json`` projection and the endpoint-start
|
|
59
|
+
narration line -- renaming a label changes both surfaces.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
sync_mode: The endpoint's declared sync mode.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
``'snapshot'``, ``'watermark'``, or ``'feed'``.
|
|
66
|
+
"""
|
|
67
|
+
match sync_mode:
|
|
68
|
+
case SnapshotMode():
|
|
69
|
+
return 'snapshot'
|
|
70
|
+
case WatermarkMode():
|
|
71
|
+
return 'watermark'
|
|
72
|
+
case FeedMode():
|
|
73
|
+
return 'feed'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _serialize_cursor(
|
|
77
|
+
cursor: IncrementalCursor | None,
|
|
78
|
+
) -> tuple[str | None, str | None]:
|
|
79
|
+
"""Serialize a stored cursor to the metadata projection's plain pair.
|
|
80
|
+
|
|
81
|
+
The storage face never sees the cursor union (the §11 storage/state
|
|
82
|
+
boundary), so the projection flattens it here; the kind labels are the
|
|
83
|
+
cursor store's own ``CursorKind`` discriminators.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
cursor: The stored cursor, or ``None`` when none is persisted.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
``(kind, value)`` -- ``('date_watermark', <iso8601>)``,
|
|
90
|
+
``('feed_token', <token>)``, or ``(None, None)``.
|
|
91
|
+
"""
|
|
92
|
+
match cursor:
|
|
93
|
+
case DateWatermark(watermark=watermark):
|
|
94
|
+
return (CursorKind.DATE_WATERMARK.value, to_iso8601(watermark))
|
|
95
|
+
case FeedToken(from_version=from_version):
|
|
96
|
+
return (CursorKind.FEED_TOKEN.value, from_version)
|
|
97
|
+
case None:
|
|
98
|
+
return (None, None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MetadataProjection:
|
|
102
|
+
"""Projects one committed run's facts into its ``metadata.json``.
|
|
103
|
+
|
|
104
|
+
Constructed once beside the runner with the cursor read-back surface,
|
|
105
|
+
the clock, and the dataset root; ``project`` runs after each arm's
|
|
106
|
+
successful commit.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self, cursors: CursorReader, clock: Clock, dataset_root: PathInput
|
|
111
|
+
) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Args:
|
|
114
|
+
cursors: The cursor store's read surface, for the post-commit
|
|
115
|
+
cursor read-back.
|
|
116
|
+
clock: Supplies the ``generated_at`` instant.
|
|
117
|
+
dataset_root: Where the endpoint output directories live.
|
|
118
|
+
"""
|
|
119
|
+
self._cursors = cursors
|
|
120
|
+
self._clock = clock
|
|
121
|
+
self._dataset_root = dataset_root
|
|
122
|
+
|
|
123
|
+
def project(
|
|
124
|
+
self,
|
|
125
|
+
definition: EndpointDefinition[ResponseModel],
|
|
126
|
+
outcome: Executed,
|
|
127
|
+
*,
|
|
128
|
+
window: DateWindow | None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Project a committed run's facts into the endpoint's ``metadata.json``.
|
|
131
|
+
|
|
132
|
+
Runs only after a successful run has fully committed (parquet, the
|
|
133
|
+
ledger rows, the unit done-marks, the watermark prefix): the
|
|
134
|
+
outcome's counts, the run's resolved window,
|
|
135
|
+
and a cursor read-back from the store flatten into a
|
|
136
|
+
``MetadataSnapshot`` the storage face renders and atomically writes
|
|
137
|
+
(DESIGN §3).
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
definition: The endpoint that just ran.
|
|
141
|
+
outcome: The run's merged ``Executed`` outcome.
|
|
142
|
+
window: The run's resolved window, or ``None`` when it had none
|
|
143
|
+
(a snapshot or feed run, or a watermark run that only
|
|
144
|
+
re-drove leftover units).
|
|
145
|
+
|
|
146
|
+
Side Effects:
|
|
147
|
+
Writes ``metadata.json`` in the endpoint's output directory; on
|
|
148
|
+
an ``OSError``, logs at ERROR and continues.
|
|
149
|
+
"""
|
|
150
|
+
cursor_kind, cursor_value = _serialize_cursor(
|
|
151
|
+
self._cursors.get_cursor(definition.provider, definition.name)
|
|
152
|
+
)
|
|
153
|
+
snapshot = MetadataSnapshot(
|
|
154
|
+
provider=definition.provider.value,
|
|
155
|
+
endpoint=definition.name,
|
|
156
|
+
sync_mode=sync_mode_label(definition.sync_mode),
|
|
157
|
+
generated_at=self._clock.now_utc(),
|
|
158
|
+
records_fetched=outcome.records_fetched,
|
|
159
|
+
rows_written=outcome.write.rows_written,
|
|
160
|
+
duplicates_dropped=outcome.write.duplicates_dropped,
|
|
161
|
+
files_written=outcome.write.files_written,
|
|
162
|
+
deleted_partitions=outcome.write.deleted_partitions,
|
|
163
|
+
window_start=None if window is None else window.start,
|
|
164
|
+
window_end=None if window is None else window.end,
|
|
165
|
+
cursor_kind=cursor_kind,
|
|
166
|
+
cursor_value=cursor_value,
|
|
167
|
+
)
|
|
168
|
+
directory = endpoint_directory(
|
|
169
|
+
self._dataset_root, definition.provider.value, definition.name
|
|
170
|
+
)
|
|
171
|
+
# Only the file write is guarded, and only for OSError: the run is
|
|
172
|
+
# already committed (parquet, ledger, units, watermark), and the file is a
|
|
173
|
+
# cosmetic projection the next successful run rewrites -- failing a
|
|
174
|
+
# committed run over it would be worse than a stale file. A render
|
|
175
|
+
# failure is a bug and propagates. An absent endpoint directory is
|
|
176
|
+
# a healthy no-data state (a seeded-at-head feed, an empty
|
|
177
|
+
# watermark cold run) -- there is nothing to project onto, so the
|
|
178
|
+
# write is skipped quietly rather than alarmed over.
|
|
179
|
+
if not directory.exists():
|
|
180
|
+
logger.debug(
|
|
181
|
+
'metadata.json skipped: provider=%s endpoint=%s '
|
|
182
|
+
'(no data has ever landed)',
|
|
183
|
+
definition.provider.value,
|
|
184
|
+
definition.name,
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
text = render_metadata_json(snapshot)
|
|
188
|
+
try:
|
|
189
|
+
write_metadata_json(directory, text)
|
|
190
|
+
except OSError:
|
|
191
|
+
logger.exception(
|
|
192
|
+
'metadata.json write failed: provider=%s endpoint=%s',
|
|
193
|
+
definition.provider.value,
|
|
194
|
+
definition.name,
|
|
195
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/outcome.py
|
|
2
|
+
"""The run executor's result carrier: what one endpoint run produced.
|
|
3
|
+
|
|
4
|
+
A frozen tagged union the run executor returns instead of ``None``, so the caller
|
|
5
|
+
(the orchestration entry, ``entry.py``) dispatches on the outcome rather than
|
|
6
|
+
inferring it. ``Executed`` carries the fetched-row count and the write report;
|
|
7
|
+
``CaughtUp`` is the no-op marker for a run whose resume window resolved to
|
|
8
|
+
nothing -- no fetch, no writer, no ledger row. ``CaughtUp`` is reachable only on
|
|
9
|
+
the watermark arm, whose window resolution can find nothing to drive; a snapshot
|
|
10
|
+
always executes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
from fleetpull.storage import WriteResult
|
|
17
|
+
|
|
18
|
+
__all__: list[str] = ['CaughtUp', 'Executed', 'RunOutcome']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Executed:
|
|
23
|
+
"""A run that fetched and wrote.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
records_fetched: The count of records fetched across the run -- the
|
|
27
|
+
ledger's row count, distinct from ``write.rows_written`` (which dedup
|
|
28
|
+
and partitioning can make a different number).
|
|
29
|
+
write: The storage layer's write report for the run.
|
|
30
|
+
latest_observed: The folded in-window maximum event time, or ``None``
|
|
31
|
+
when the run observed no in-window event (an empty unit) or has no
|
|
32
|
+
event-time axis (a snapshot). The prefix-advance watermark rule's
|
|
33
|
+
per-unit datum (DESIGN section 5): the unit loop records it at
|
|
34
|
+
``mark_done`` and the watermark advances only across the
|
|
35
|
+
contiguous done-prefix -- never here, never per-unit in isolation.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
records_fetched: int
|
|
39
|
+
write: WriteResult
|
|
40
|
+
latest_observed: datetime | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class CaughtUp:
|
|
45
|
+
"""A run that did nothing because its resume window resolved to empty.
|
|
46
|
+
|
|
47
|
+
No fetch, no writer, no ledger row; carries no fields (the run executor logs the
|
|
48
|
+
detail). Reachable only on the watermark arm, whose window resolution can
|
|
49
|
+
resolve to nothing.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
type RunOutcome = Executed | CaughtUp
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/recording.py
|
|
2
|
+
"""Record-failure-without-masking: the orchestration layer's shared stance.
|
|
3
|
+
|
|
4
|
+
Every failure-recording write in this layer -- the run executor marking a run
|
|
5
|
+
failed, the roster coordinator marking a harvest run failed, the unit loop
|
|
6
|
+
marking a work unit failed -- touches SQLite, which can itself fail (a locked
|
|
7
|
+
or unwritable database); if it does, that secondary failure must never
|
|
8
|
+
replace the error that actually ended the work. ``record_failure_safely``
|
|
9
|
+
states that stance once: run the recording call, and on any recording failure
|
|
10
|
+
log it and swallow it so the original propagates. ``recorded_run`` is the
|
|
11
|
+
run-scoped composition every run-opening arm wraps its protected block in:
|
|
12
|
+
on any failure inside the block, record the run failed (safely) and re-raise
|
|
13
|
+
the original.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Callable, Iterator
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from functools import partial
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
__all__: list[str] = ['FailureRecorder', 'record_failure_safely', 'recorded_run']
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FailureRecorder(Protocol):
|
|
28
|
+
"""The one-method fail-a-run surface ``recorded_run`` needs."""
|
|
29
|
+
|
|
30
|
+
def fail_run(self, run_id: int, *, error_detail: str) -> None:
|
|
31
|
+
"""Close a run as failed with an error detail."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def record_failure_safely(record: Callable[[], None], subject: str) -> None:
|
|
36
|
+
"""Run a failure-recording call without masking the original error.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
record: The recording call (``fail_run`` / ``mark_failed``, already
|
|
40
|
+
bound to its arguments).
|
|
41
|
+
subject: What is being marked failed, for the log line (e.g.
|
|
42
|
+
``'run 5'``, ``'work unit 7'``).
|
|
43
|
+
|
|
44
|
+
Side Effects:
|
|
45
|
+
Runs ``record``; on a recording failure, logs it with its traceback
|
|
46
|
+
and swallows it so the caller's original error propagates.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
record()
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.exception(
|
|
52
|
+
'failed to record %s as failed after an earlier error', subject
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def recorded_run(recorder: FailureRecorder, run_id: int) -> Iterator[None]:
|
|
58
|
+
"""Run the block; on any failure, record the run failed and re-raise.
|
|
59
|
+
|
|
60
|
+
The shared spine of every run-opening arm's protected block: a failure
|
|
61
|
+
inside marks the run failed through ``record_failure_safely`` (so a
|
|
62
|
+
recording failure never masks the original) and the original error
|
|
63
|
+
propagates unchanged.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
recorder: The ledger surface whose ``fail_run`` closes the run.
|
|
67
|
+
run_id: The open run to mark failed on a block failure.
|
|
68
|
+
|
|
69
|
+
Yields:
|
|
70
|
+
Nothing -- the protected block runs in the ``with`` body.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
Exception: Whatever the block raised, unchanged.
|
|
74
|
+
|
|
75
|
+
Side Effects:
|
|
76
|
+
On a block failure, records the run failed (best-effort).
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
yield
|
|
80
|
+
except Exception as error:
|
|
81
|
+
record_failure_safely(
|
|
82
|
+
partial(recorder.fail_run, run_id, error_detail=str(error)),
|
|
83
|
+
f'run {run_id}',
|
|
84
|
+
)
|
|
85
|
+
raise
|