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,347 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/entry.py
|
|
2
|
+
"""The orchestration entry: declarations in, run outcome out.
|
|
3
|
+
|
|
4
|
+
``run_endpoint`` is the caller boundary one layer above ``EndpointRunner``: it
|
|
5
|
+
resolves the endpoint's declared request driver and runs. The governing
|
|
6
|
+
principle: higher-level orchestrators and tools are polymorphic --
|
|
7
|
+
provider-agnostic and endpoint-agnostic. A caller invoking an endpoint never
|
|
8
|
+
knows or branches on the provider, its request shape, its sync mode, its
|
|
9
|
+
storage cell, or its record identity; every dispatch keys off
|
|
10
|
+
``EndpointDefinition`` declarations (the ``RequestShape`` union and
|
|
11
|
+
``select_writer`` already state this for their seams -- this module extends
|
|
12
|
+
it to driver resolution). The shape-to-driver dispatch itself lives on the
|
|
13
|
+
shared seam (``shape_resolution.resolve_request_driver``, which ``fetch``
|
|
14
|
+
also calls); what this entry owns is the roster half it feeds that seam: a
|
|
15
|
+
roster-backed shape -- ``RosterFanOut``, or the ``BatchedRosterFanOut``
|
|
16
|
+
that packs one into comma-joined batches -- resolves its ``RosterKey``
|
|
17
|
+
through the ``RosterRegistry``,
|
|
18
|
+
refreshes the membership via the coordinator -- which owns the entire
|
|
19
|
+
staleness policy, including best-effort degradation and the loud cold-start
|
|
20
|
+
failure; the entry never reasons about freshness -- then reads the members
|
|
21
|
+
from the store. An empty roster after the refresh raises
|
|
22
|
+
``ConfigurationError``, error-by-default (DESIGN section 13): a feeder that
|
|
23
|
+
listed nothing is a failure to surface, not an empty dataset to emit, and the
|
|
24
|
+
short-circuit keeps the writer's write-called-at-least-once precondition
|
|
25
|
+
intact (an ``allow_empty_roster`` escape joins ``RosterFanOut`` only when an
|
|
26
|
+
endpoint genuinely needs one). Every non-roster-backed shape touches no
|
|
27
|
+
roster machinery at all (unless the endpoint sources a roster -- below).
|
|
28
|
+
|
|
29
|
+
The entry also owns the feeder tap (the other half of the coupling rule:
|
|
30
|
+
every execution of a feeder endpoint updates its rosters and records a run;
|
|
31
|
+
parquet is written only when the user asked for that endpoint). It reverse-
|
|
32
|
+
looks-up the rosters the definition sources (``RosterRegistry.sourced_by``);
|
|
33
|
+
when any exist, it installs a generic batch observer that collects each
|
|
34
|
+
sourced roster's distinct ``source_column`` values -- values only, never
|
|
35
|
+
frames, so memory stays bounded by the distinct-key count -- and, after the
|
|
36
|
+
run returns ``Executed``, hands each collected listing to the coordinator's
|
|
37
|
+
``apply_listing``, whose reconcile guard rejects an empty listing loudly (a
|
|
38
|
+
roster is never reconciled to empty; the failure propagates and fails the
|
|
39
|
+
endpoint while the prior membership stands). On a failed run nothing is
|
|
40
|
+
applied; on ``CaughtUp`` nothing executed, so there is no listing to apply.
|
|
41
|
+
A sourced definition that is not snapshot-mode is rejected before anything
|
|
42
|
+
runs -- ``reconcile`` is only correct over a complete listing, which only a
|
|
43
|
+
snapshot-mode feeder produces, so a watermark-mode source is a wiring bug
|
|
44
|
+
(the same guard the refresh coordinator applies on its harvest route). The
|
|
45
|
+
runner stays roster-blind: it sees only the generic observer.
|
|
46
|
+
|
|
47
|
+
Stateful collaborators are narrow Protocols (``EndpointExecutor``,
|
|
48
|
+
``RosterRefresher``, ``RosterMembersReader``, and the seam's
|
|
49
|
+
``FetchPoolSource``) the composition root satisfies with the real runner,
|
|
50
|
+
coordinator, store, and fetch-pool registry; the ``RosterRegistry`` is the
|
|
51
|
+
immutable catalog passed concrete (the run-executor precedent, DESIGN
|
|
52
|
+
section 14). The three roster collaborators always travel together, so they
|
|
53
|
+
ride as one ``RosterMachinery`` bundle (the bundle rule).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
import logging
|
|
57
|
+
from collections.abc import Sequence
|
|
58
|
+
from dataclasses import dataclass
|
|
59
|
+
from functools import partial
|
|
60
|
+
from typing import Protocol
|
|
61
|
+
|
|
62
|
+
import polars as pl
|
|
63
|
+
|
|
64
|
+
from fleetpull.endpoints.shared import EndpointDefinition
|
|
65
|
+
from fleetpull.exceptions import ConfigurationError
|
|
66
|
+
from fleetpull.model_contract import ResponseModel
|
|
67
|
+
from fleetpull.orchestrator.drivers import RequestDriver
|
|
68
|
+
from fleetpull.orchestrator.outcome import CaughtUp, Executed, RunOutcome
|
|
69
|
+
from fleetpull.orchestrator.roster_harvest import require_snapshot_feeder
|
|
70
|
+
from fleetpull.orchestrator.shape_resolution import (
|
|
71
|
+
FetchPoolSource,
|
|
72
|
+
resolve_request_driver,
|
|
73
|
+
)
|
|
74
|
+
from fleetpull.orchestrator.streaming import BatchObserver
|
|
75
|
+
from fleetpull.records import extract_roster_members
|
|
76
|
+
from fleetpull.roster import RosterDefinition, RosterKey, RosterRegistry
|
|
77
|
+
|
|
78
|
+
__all__: list[str] = [
|
|
79
|
+
'EndpointExecutor',
|
|
80
|
+
'RosterMachinery',
|
|
81
|
+
'RosterMembersReader',
|
|
82
|
+
'RosterRefresher',
|
|
83
|
+
'run_endpoint',
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
logger = logging.getLogger(__name__)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EndpointExecutor(Protocol):
|
|
90
|
+
"""The run surface the entry needs (a subset of ``EndpointRunner``)."""
|
|
91
|
+
|
|
92
|
+
def run(
|
|
93
|
+
self,
|
|
94
|
+
definition: EndpointDefinition[ResponseModel],
|
|
95
|
+
driver: RequestDriver,
|
|
96
|
+
observer: BatchObserver | None = None,
|
|
97
|
+
) -> RunOutcome:
|
|
98
|
+
"""Run one endpoint to completion with the resolved driver."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RosterRefresher(Protocol):
|
|
103
|
+
"""The roster-policy surface the entry needs (``RosterRefreshCoordinator``'s shape)."""
|
|
104
|
+
|
|
105
|
+
def refresh_if_stale(self, definition: RosterDefinition) -> None:
|
|
106
|
+
"""Re-list the roster's feeder and reconcile the membership if stale."""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
def apply_listing(self, definition: RosterDefinition, listed: set[str]) -> None:
|
|
110
|
+
"""Reconcile an executed feeder run's listed membership into the store."""
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RosterMembersReader(Protocol):
|
|
115
|
+
"""The member-read surface the entry needs (a subset of ``RosterStore``)."""
|
|
116
|
+
|
|
117
|
+
def read_members(self, key: RosterKey) -> list[str]:
|
|
118
|
+
"""Return the roster's members, ascending; empty when none stored."""
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class RosterMachinery:
|
|
124
|
+
"""The three roster collaborators a run consults, bundled.
|
|
125
|
+
|
|
126
|
+
They always travel together -- the fan-out resolution reads all three and
|
|
127
|
+
the feeder tap reads the catalog -- so they ride as one parameter (the
|
|
128
|
+
bundle rule). The composition root builds it once per run from the
|
|
129
|
+
discovered catalog, the refresh coordinator, and the roster store.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
registry: The immutable roster catalog -- forward lookup for a
|
|
133
|
+
fan-out binding, reverse lookup for the rosters a definition
|
|
134
|
+
sources.
|
|
135
|
+
refresher: The roster-policy coordinator; owns staleness,
|
|
136
|
+
degradation, cold-start, and reconciliation whole.
|
|
137
|
+
members: The stored-membership read surface.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
registry: RosterRegistry
|
|
141
|
+
refresher: RosterRefresher
|
|
142
|
+
members: RosterMembersReader
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _RosterMemberCollector:
|
|
146
|
+
"""Collects each sourced roster's distinct members from observed batches.
|
|
147
|
+
|
|
148
|
+
The feeder tap's accumulator: for every roster the running endpoint
|
|
149
|
+
sources, it extracts the distinct ``source_column`` values from each
|
|
150
|
+
post-validation frame the runner hands the observer. Values only, never
|
|
151
|
+
frames -- memory stays bounded by the distinct-key count, not the run's
|
|
152
|
+
row count.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, sourced: Sequence[RosterDefinition]) -> None:
|
|
156
|
+
self._sourced = sourced
|
|
157
|
+
self._members: dict[RosterKey, set[str]] = {
|
|
158
|
+
definition.key: set() for definition in sourced
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
def observe(self, frame: pl.DataFrame) -> None:
|
|
162
|
+
"""Fold one post-validation frame's members into each sourced roster.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
frame: A validated, flattened batch frame from the run.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ValueError: A sourced ``source_column`` is absent from the frame
|
|
169
|
+
(from ``extract_roster_members``) -- a wiring bug that fails
|
|
170
|
+
the run loudly. Null and empty-string values do not raise; the
|
|
171
|
+
extractor filters them loudly.
|
|
172
|
+
"""
|
|
173
|
+
for definition in self._sourced:
|
|
174
|
+
self._members[definition.key] |= extract_roster_members(
|
|
175
|
+
frame, definition.source_column
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def members_for(self, key: RosterKey) -> set[str]:
|
|
179
|
+
"""Return the collected membership for one sourced roster."""
|
|
180
|
+
return self._members[key]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run_endpoint(
|
|
184
|
+
definition: EndpointDefinition[ResponseModel],
|
|
185
|
+
runner: EndpointExecutor,
|
|
186
|
+
rosters: RosterMachinery,
|
|
187
|
+
fetch_pools: FetchPoolSource,
|
|
188
|
+
) -> RunOutcome:
|
|
189
|
+
"""Run one endpoint through its declared request driver, tapping feeder runs.
|
|
190
|
+
|
|
191
|
+
The provider- and endpoint-agnostic entry: dispatch keys off the
|
|
192
|
+
definition's declared ``request_shape`` (via the shared shape-resolution
|
|
193
|
+
seam) and the roster catalog only. The roster-backed shapes
|
|
194
|
+
(``RosterFanOut`` and ``BatchedRosterFanOut``) are fed a refreshed
|
|
195
|
+
roster membership and fanned out over the provider's fetch pool;
|
|
196
|
+
every other shape resolves with no roster machinery touched. When
|
|
197
|
+
the endpoint sources any roster (``sourced_by``), the run is observed
|
|
198
|
+
and -- on ``Executed`` -- each sourced roster is reconciled from the run's
|
|
199
|
+
collected members, so every feeder execution updates its rosters (the
|
|
200
|
+
coupling rule; the run row comes from the runner, and parquet was written
|
|
201
|
+
because the user asked for this endpoint). An endpoint that neither fans
|
|
202
|
+
out over a roster nor sources one touches no roster machinery at all.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
definition: The endpoint to run, already resolved by the caller.
|
|
206
|
+
runner: The run executor (``EndpointRunner``'s ``run`` surface).
|
|
207
|
+
rosters: The roster catalog, policy coordinator, and stored-membership
|
|
208
|
+
read, bundled.
|
|
209
|
+
fetch_pools: The per-provider fetch pools (``FetchPoolRegistry``'s
|
|
210
|
+
``pool_for`` surface); consulted only on the fanned shapes.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
The run outcome (``Executed`` or ``CaughtUp``), unchanged from the
|
|
214
|
+
runner.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
ConfigurationError: The shape names an unregistered roster; the
|
|
218
|
+
roster is empty after the refresh (error-by-default -- a feeder
|
|
219
|
+
that listed nothing is a failure to surface); or the definition
|
|
220
|
+
sources a roster but is not snapshot-mode (the feeder-mode guard:
|
|
221
|
+
reconcile is only correct over a complete listing, which only a
|
|
222
|
+
snapshot feeder produces).
|
|
223
|
+
FleetpullError: A cold-start roster refresh failed (propagated from
|
|
224
|
+
the coordinator), or the run itself failed (propagated from the
|
|
225
|
+
runner; a failed run applies nothing to any roster).
|
|
226
|
+
|
|
227
|
+
Side Effects:
|
|
228
|
+
Whatever the refresh and the run perform: network fetches, parquet
|
|
229
|
+
writes, and state-store commits by the injected collaborators; on a
|
|
230
|
+
successful feeder run, the sourced rosters' reconciled deltas.
|
|
231
|
+
"""
|
|
232
|
+
sourced = rosters.registry.sourced_by(definition.provider, definition.name)
|
|
233
|
+
if sourced:
|
|
234
|
+
# The shared guard covers both reconcile routes: here the tap route,
|
|
235
|
+
# and the refresh coordinator's harvest route. A wiring bug, not a
|
|
236
|
+
# degradable condition.
|
|
237
|
+
require_snapshot_feeder(definition, definition.name)
|
|
238
|
+
driver = _resolve_driver(definition, rosters, fetch_pools)
|
|
239
|
+
if not sourced:
|
|
240
|
+
return runner.run(definition, driver)
|
|
241
|
+
collector = _RosterMemberCollector(sourced)
|
|
242
|
+
outcome = runner.run(definition, driver, collector.observe)
|
|
243
|
+
match outcome:
|
|
244
|
+
case Executed():
|
|
245
|
+
for roster_definition in sourced:
|
|
246
|
+
rosters.refresher.apply_listing(
|
|
247
|
+
roster_definition, collector.members_for(roster_definition.key)
|
|
248
|
+
)
|
|
249
|
+
case CaughtUp():
|
|
250
|
+
# Nothing executed, so nothing was listed -- an unexecuted run
|
|
251
|
+
# must not reconcile (an empty "listing" would count absences).
|
|
252
|
+
pass
|
|
253
|
+
return outcome
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _resolve_driver(
|
|
257
|
+
definition: EndpointDefinition[ResponseModel],
|
|
258
|
+
rosters: RosterMachinery,
|
|
259
|
+
fetch_pools: FetchPoolSource,
|
|
260
|
+
) -> RequestDriver:
|
|
261
|
+
"""Resolve the definition's declared request shape into a request driver.
|
|
262
|
+
|
|
263
|
+
A thin composition over the shared seam
|
|
264
|
+
(``shape_resolution.resolve_request_driver``): the shape-to-driver
|
|
265
|
+
dispatch is the seam's; what the entry contributes is the roster member
|
|
266
|
+
source -- the stateful half only this composition has. Module-private by
|
|
267
|
+
design: the shape distinctions are the entry's to hide, not the caller's
|
|
268
|
+
to compose with.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
definition: The endpoint whose ``request_shape`` routes.
|
|
272
|
+
rosters: Resolves a ``RosterFanOut``'s ``RosterKey``, refreshes the
|
|
273
|
+
membership when stale, and reads it back.
|
|
274
|
+
fetch_pools: Supplies the provider's fetch pool for the fanned shapes.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The seam's resolved driver.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ConfigurationError: The shape names an unregistered roster (from
|
|
281
|
+
the registry), or the roster is empty after the refresh.
|
|
282
|
+
FleetpullError: A cold-start refresh failure, propagated unswallowed
|
|
283
|
+
from the coordinator.
|
|
284
|
+
|
|
285
|
+
Side Effects:
|
|
286
|
+
On the roster fan-out path: whatever the refresh performs (a feeder
|
|
287
|
+
listing and a store write when stale).
|
|
288
|
+
"""
|
|
289
|
+
return resolve_request_driver(
|
|
290
|
+
definition,
|
|
291
|
+
fetch_pools=fetch_pools,
|
|
292
|
+
roster_members=partial(_refreshed_roster_members, definition, rosters),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _refreshed_roster_members(
|
|
297
|
+
definition: EndpointDefinition[ResponseModel],
|
|
298
|
+
rosters: RosterMachinery,
|
|
299
|
+
roster: RosterKey,
|
|
300
|
+
) -> Sequence[str]:
|
|
301
|
+
"""Resolve a named roster's refreshed membership -- the seam's feed.
|
|
302
|
+
|
|
303
|
+
The roster machinery whole: registry lookup, the coordinator's refresh
|
|
304
|
+
(staleness policy included), the store read, and the empty-roster guard.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
definition: The endpoint being run, for the error context.
|
|
308
|
+
rosters: The roster catalog, policy coordinator, and stored-membership
|
|
309
|
+
read, bundled.
|
|
310
|
+
roster: The roster the declared shape names.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
The roster's members, ascending, never empty.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
ConfigurationError: The shape names an unregistered roster (from the
|
|
317
|
+
registry), or the roster is empty after the refresh
|
|
318
|
+
(error-by-default -- a feeder that listed nothing is a failure to
|
|
319
|
+
surface, not an empty dataset to emit).
|
|
320
|
+
FleetpullError: A cold-start refresh failure, propagated unswallowed
|
|
321
|
+
from the coordinator.
|
|
322
|
+
|
|
323
|
+
Side Effects:
|
|
324
|
+
Whatever the refresh performs (a feeder listing and a store write
|
|
325
|
+
when stale).
|
|
326
|
+
"""
|
|
327
|
+
roster_definition = rosters.registry.get(roster)
|
|
328
|
+
rosters.refresher.refresh_if_stale(roster_definition)
|
|
329
|
+
members = rosters.members.read_members(roster)
|
|
330
|
+
if not members:
|
|
331
|
+
raise ConfigurationError(
|
|
332
|
+
'fan-out roster is empty',
|
|
333
|
+
provider=definition.provider.value,
|
|
334
|
+
endpoint=definition.name,
|
|
335
|
+
detail=(
|
|
336
|
+
f'roster {roster.name!r} holds no members after refresh; '
|
|
337
|
+
f'a fan-out over nothing is a failure to surface, not an empty '
|
|
338
|
+
f'dataset to emit'
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
logger.debug(
|
|
342
|
+
'fan-out resolved: endpoint=%s roster=%s members=%d',
|
|
343
|
+
definition.name,
|
|
344
|
+
roster.name,
|
|
345
|
+
len(members),
|
|
346
|
+
)
|
|
347
|
+
return members
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/executors.py
|
|
2
|
+
"""Provider-keyed registry of fetch pools: one worker pool per provider.
|
|
3
|
+
|
|
4
|
+
The per-provider executor of DESIGN §7, owned by ``Sync``'s composition root
|
|
5
|
+
as a context-managed collaborator (the ``ProviderClientRegistry`` precedent):
|
|
6
|
+
``__enter__`` creates one ``ThreadPoolExecutor`` per configured provider,
|
|
7
|
+
sized ``max_workers = rate_limit.max_concurrency``, and ``__exit__`` shuts
|
|
8
|
+
every pool down deterministically -- success or failure, no leaked threads.
|
|
9
|
+
The lifecycle machinery (publish-on-success enter, closed-before-release
|
|
10
|
+
exit, the RuntimeError-vs-ConfigurationError lookup split) is the generic
|
|
11
|
+
``ProviderResourceRegistry``'s (the network client face); this subclass
|
|
12
|
+
supplies pool construction and the error nouns.
|
|
13
|
+
Per-provider pools by construction, so one provider's 429 penalty can never
|
|
14
|
+
park another provider's workers (the starvation fix), and a fan-out run's
|
|
15
|
+
in-flight ceiling equals the limiter's concurrency semaphore, which the pool
|
|
16
|
+
never replaces -- workers acquire tokens and the semaphore exactly as the
|
|
17
|
+
serial path did.
|
|
18
|
+
|
|
19
|
+
``pool_for`` hands out the provider's ``FetchPool`` -- the executor plus the
|
|
20
|
+
bounded-channel window ``stream_pieces`` enforces. The window is fixed here,
|
|
21
|
+
at the one place the worker count is known, to twice that count: a finishing
|
|
22
|
+
worker always has a queued piece to pick up while the consumer writes, and
|
|
23
|
+
the fetched-but-unconsumed bound stays a small multiple of the pool size.
|
|
24
|
+
|
|
25
|
+
Since the intra-provider grain (DESIGN §7, 2026-07-20) one pool may serve
|
|
26
|
+
multiple concurrent fan-out endpoints: a submission window is
|
|
27
|
+
per-``stream_pieces``-call local state, so each concurrent stream keeps its
|
|
28
|
+
own window over the shared executor -- execution stays capped by
|
|
29
|
+
``max_workers`` and in-flight requests by the limiter's semaphore, so the
|
|
30
|
+
in-flight ceiling claim above holds unchanged and nothing here resized.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
35
|
+
from contextlib import ExitStack
|
|
36
|
+
from typing import ClassVar
|
|
37
|
+
|
|
38
|
+
from fleetpull.network.client import ProviderResourceRegistry
|
|
39
|
+
from fleetpull.orchestrator.fanout import FetchPool
|
|
40
|
+
from fleetpull.vocabulary import Provider
|
|
41
|
+
|
|
42
|
+
__all__: list[str] = ['FetchPoolRegistry']
|
|
43
|
+
|
|
44
|
+
# The submission window as a multiple of the worker count: 2x keeps a queued
|
|
45
|
+
# piece ready for every finishing worker (plain double-buffering) while the
|
|
46
|
+
# streaming bound stays proportional to the pool, never the roster.
|
|
47
|
+
_SUBMISSION_WINDOW_FACTOR: int = 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FetchPoolRegistry(ProviderResourceRegistry[FetchPool]):
|
|
51
|
+
"""Owns one fetch worker pool per provider, keyed by ``Provider``.
|
|
52
|
+
|
|
53
|
+
A resource-owning context manager (the generic base's semantics):
|
|
54
|
+
``__exit__`` shuts each pool down with ``wait=True``, so no worker thread
|
|
55
|
+
outlives the run that composed it, on success and on failure alike.
|
|
56
|
+
``pool_for`` returns a provider's ``FetchPool``; use it only inside the
|
|
57
|
+
``with`` block::
|
|
58
|
+
|
|
59
|
+
with FetchPoolRegistry(workers_by_provider) as fetch_pools:
|
|
60
|
+
pool = fetch_pools.pool_for(definition.provider)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_resource_noun: ClassVar[str] = 'fetch pool'
|
|
64
|
+
_lookup_description: ClassVar[str] = 'pool_for'
|
|
65
|
+
|
|
66
|
+
def __init__(self, workers_by_provider: Mapping[Provider, int]) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Args:
|
|
69
|
+
workers_by_provider: Each configured provider's worker count --
|
|
70
|
+
``rate_limit.max_concurrency`` from that provider's resolved
|
|
71
|
+
config, so the pool can never outrun the limiter's in-flight
|
|
72
|
+
semaphore. A provider absent here has no pool and is rejected
|
|
73
|
+
by ``pool_for`` while the registry is open.
|
|
74
|
+
|
|
75
|
+
Side Effects:
|
|
76
|
+
None -- pools are created on ``__enter__``, not here.
|
|
77
|
+
"""
|
|
78
|
+
super().__init__(workers_by_provider)
|
|
79
|
+
self._workers_by_provider: dict[Provider, int] = dict(workers_by_provider)
|
|
80
|
+
|
|
81
|
+
def _open_resource(self, stack: ExitStack, provider: Provider) -> FetchPool:
|
|
82
|
+
"""Create one provider's worker pool with its fixed submission window.
|
|
83
|
+
|
|
84
|
+
``ThreadPoolExecutor.__exit__`` is ``shutdown(wait=True)``: every
|
|
85
|
+
worker thread is joined when the stack unwinds, so a failing run
|
|
86
|
+
cannot leak threads into the caller.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
stack: The enter's unwind stack; the pool's shutdown registers
|
|
90
|
+
here.
|
|
91
|
+
provider: The provider whose pool to create.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The provider's ``FetchPool``.
|
|
95
|
+
|
|
96
|
+
Side Effects:
|
|
97
|
+
Constructs one ``ThreadPoolExecutor`` (threads spawn lazily on
|
|
98
|
+
first submit).
|
|
99
|
+
"""
|
|
100
|
+
worker_count = self._workers_by_provider[provider]
|
|
101
|
+
executor = stack.enter_context(
|
|
102
|
+
ThreadPoolExecutor(
|
|
103
|
+
max_workers=worker_count,
|
|
104
|
+
thread_name_prefix=f'fleetpull-{provider.value}-fetch',
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return FetchPool(
|
|
108
|
+
executor=executor,
|
|
109
|
+
submission_window=_SUBMISSION_WINDOW_FACTOR * worker_count,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def pool_for(self, provider: Provider) -> FetchPool:
|
|
113
|
+
"""Return the fetch pool for a provider.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
provider: The provider whose pool to return (e.g.
|
|
117
|
+
``definition.provider``).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The provider's ``FetchPool``.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
RuntimeError: The registry is not open -- ``pool_for`` was called
|
|
124
|
+
outside an active ``with`` block (a caller bug).
|
|
125
|
+
ConfigurationError: The registry is open but the provider has no
|
|
126
|
+
configured pool.
|
|
127
|
+
"""
|
|
128
|
+
return self._resource_for(provider)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# src/fleetpull/orchestrator/fanout.py
|
|
2
|
+
"""The bounded fan-out channel: worker-fetched pieces, streamed to one consumer.
|
|
3
|
+
|
|
4
|
+
``stream_pieces`` is the concurrency spine of a fan-out run (DESIGN §7): the
|
|
5
|
+
caller supplies one task per (member x window) piece, workers on the injected
|
|
6
|
+
executor fetch whole pieces, and the consumer thread -- the only thread that
|
|
7
|
+
ever validates, frames, or writes -- receives each piece's items as a lazy
|
|
8
|
+
stream. The channel is a bounded submission window over futures, drained in
|
|
9
|
+
submission order:
|
|
10
|
+
|
|
11
|
+
- **Bounded, never collected.** At most ``pool.submission_window`` pieces are
|
|
12
|
+
outstanding, plus the one being yielded -- the fetched-but-unconsumed count
|
|
13
|
+
never exceeds ``submission_window + 1``, a function of the pool size, never
|
|
14
|
+
of the member count. When the consumer lags, no new piece is submitted, so
|
|
15
|
+
workers idle instead of burning rate-budget tokens on results that cannot
|
|
16
|
+
yet be written.
|
|
17
|
+
- **Submission-order draining.** The consumer waits on the oldest outstanding
|
|
18
|
+
future, so items yield in exactly the order a serial loop would produce
|
|
19
|
+
them -- correctness never depends on which piece completes first, and a
|
|
20
|
+
synchronous same-thread executor reproduces the serial path verbatim.
|
|
21
|
+
- **First failure wins.** The first exception the consumer encounters
|
|
22
|
+
re-raises after the window unwinds: not-yet-started pieces are cancelled
|
|
23
|
+
(unsubmitted members are never submitted at all), in-flight pieces finish
|
|
24
|
+
and their results are discarded, and any discarded piece's own failure is
|
|
25
|
+
logged, never raised over the first.
|
|
26
|
+
|
|
27
|
+
Workers touch the network and this channel, nothing else; the single-writer
|
|
28
|
+
invariant (DESIGN §3) is the consumer's side of the contract, not enforced
|
|
29
|
+
here. The limiter remains the only enforcement point on the wire -- the pool
|
|
30
|
+
merely supplies workers, each of which acquires tokens and the concurrency
|
|
31
|
+
semaphore exactly as the serial path does.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
from collections.abc import Callable, Generator, Iterable, Sequence
|
|
36
|
+
from concurrent.futures import Executor, Future
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
|
|
39
|
+
__all__: list[str] = ['FetchPool', 'stream_pieces']
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class FetchPool:
|
|
46
|
+
"""One provider's fetch workers plus the channel bound they imply.
|
|
47
|
+
|
|
48
|
+
The pair travels together because the bound is a property of the pool:
|
|
49
|
+
``Executor`` does not expose its worker count, so the composition root
|
|
50
|
+
that sizes the executor also fixes the submission window (the registry
|
|
51
|
+
sets it to twice the worker count -- workers always have a queued piece
|
|
52
|
+
to pick up while the consumer writes). Tests inject a synchronous
|
|
53
|
+
same-thread executor through this same seam.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
executor: The worker supply for piece fetches. Lifecycle belongs to
|
|
57
|
+
whoever composed it (``FetchPoolRegistry`` in production) --
|
|
58
|
+
``stream_pieces`` never shuts it down.
|
|
59
|
+
submission_window: Maximum pieces outstanding at once (>= 1). The
|
|
60
|
+
streaming bound is ``submission_window + 1`` fetched-but-unconsumed
|
|
61
|
+
pieces (the window plus the piece being yielded).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
executor: Executor
|
|
65
|
+
submission_window: int
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
"""Reject a window that could never stream.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: ``submission_window`` is less than 1 -- a zero window
|
|
72
|
+
would prime nothing and silently yield an empty run.
|
|
73
|
+
"""
|
|
74
|
+
if self.submission_window < 1:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f'submission_window must be >= 1, got {self.submission_window}'
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _discard_outstanding[PieceItem](
|
|
81
|
+
outstanding: Iterable[Future[Sequence[PieceItem]]],
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Unwind the window: cancel what has not started, discard what has.
|
|
84
|
+
|
|
85
|
+
Cancels every outstanding future (a no-op for ones already running),
|
|
86
|
+
waits for the in-flight ones to finish, and logs -- never raises -- any
|
|
87
|
+
failure among the discarded results, so a winning first exception (or the
|
|
88
|
+
consumer's own abandonment) is never masked by a straggler's.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
outstanding: The window's remaining futures, oldest first.
|
|
92
|
+
|
|
93
|
+
Side Effects:
|
|
94
|
+
Blocks until every in-flight piece finishes; logs each discarded
|
|
95
|
+
piece's exception at ERROR.
|
|
96
|
+
"""
|
|
97
|
+
for future in outstanding:
|
|
98
|
+
future.cancel()
|
|
99
|
+
for future in outstanding:
|
|
100
|
+
if future.cancelled():
|
|
101
|
+
continue
|
|
102
|
+
discarded_error = future.exception()
|
|
103
|
+
if discarded_error is not None:
|
|
104
|
+
logger.error(
|
|
105
|
+
'discarding an in-flight fan-out piece that failed after the '
|
|
106
|
+
'run was already unwinding',
|
|
107
|
+
exc_info=discarded_error,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def stream_pieces[PieceItem](
|
|
112
|
+
piece_tasks: Iterable[Callable[[], Sequence[PieceItem]]],
|
|
113
|
+
pool: FetchPool,
|
|
114
|
+
) -> Generator[PieceItem, None, None]:
|
|
115
|
+
"""Fetch pieces on the pool's workers; stream their items in task order.
|
|
116
|
+
|
|
117
|
+
Primes the window with the first ``pool.submission_window`` tasks, then
|
|
118
|
+
repeatedly: wait on the oldest future, submit the next task (so workers
|
|
119
|
+
stay busy while the consumer processes), and yield the finished piece's
|
|
120
|
+
items. Tasks past the window are submitted only as earlier pieces are
|
|
121
|
+
consumed -- the backpressure that keeps fetched-but-unconsumed results at
|
|
122
|
+
``submission_window + 1`` pieces, never a function of task count.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
piece_tasks: One callable per piece, each returning that piece's
|
|
126
|
+
items; consumed lazily, in order. Each runs on a worker thread
|
|
127
|
+
and must touch only what is safe there (the network, for a
|
|
128
|
+
fan-out fetch).
|
|
129
|
+
pool: The workers and the window bound, as one collaborator.
|
|
130
|
+
|
|
131
|
+
Yields:
|
|
132
|
+
Every piece's items, piece by piece, in task order -- the order a
|
|
133
|
+
serial loop over ``piece_tasks`` would produce.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
Exception: The first failure among the pieces, re-raised unchanged
|
|
137
|
+
after the window unwinds (later pieces cancelled or discarded;
|
|
138
|
+
a discarded piece's own failure is logged, never raised).
|
|
139
|
+
|
|
140
|
+
Side Effects:
|
|
141
|
+
Submits work to ``pool.executor``; on failure or an abandoning
|
|
142
|
+
consumer (``close()``), cancels and drains the window before
|
|
143
|
+
returning. Never shuts the executor down.
|
|
144
|
+
"""
|
|
145
|
+
tasks = iter(piece_tasks)
|
|
146
|
+
window: list[Future[Sequence[PieceItem]]] = []
|
|
147
|
+
try:
|
|
148
|
+
for task in tasks:
|
|
149
|
+
window.append(pool.executor.submit(task))
|
|
150
|
+
if len(window) >= pool.submission_window:
|
|
151
|
+
break
|
|
152
|
+
while window:
|
|
153
|
+
piece = window.pop(0).result()
|
|
154
|
+
next_task = next(tasks, None)
|
|
155
|
+
if next_task is not None:
|
|
156
|
+
window.append(pool.executor.submit(next_task))
|
|
157
|
+
yield from piece
|
|
158
|
+
finally:
|
|
159
|
+
# Reached with a non-empty window only when the stream is dying: a
|
|
160
|
+
# piece's exception is propagating, or the consumer closed the
|
|
161
|
+
# generator mid-stream. Either way the window unwinds before the
|
|
162
|
+
# cause continues outward.
|
|
163
|
+
_discard_outstanding(window)
|