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,648 @@
|
|
|
1
|
+
# src/fleetpull/state/work_units.py
|
|
2
|
+
"""The work-units store: the backfill claim queue.
|
|
3
|
+
|
|
4
|
+
A backfill decomposes a (provider, endpoint) range into date chunks — and an
|
|
5
|
+
opaque ``partition_key`` (a vehicle id, a driver id, ...) for partitioned
|
|
6
|
+
endpoints — and this store is the claim queue over those units. It is dumb
|
|
7
|
+
persistence plus an atomic claim: it stores the units a caller plans, hands them
|
|
8
|
+
to fetch workers one at a time, records each outcome, and recovers from a crash —
|
|
9
|
+
knowing nothing about HTTP, parquet, chunking, or what a ``partition_key`` means
|
|
10
|
+
(DESIGN §5). Runs after ``migrate_to_head`` — the ``work_units`` table must exist.
|
|
11
|
+
|
|
12
|
+
Enqueue is idempotent (``INSERT OR IGNORE`` on the natural key, with partial unique
|
|
13
|
+
indexes so a NULL ``partition_key`` still dedups), so re-running a backfill plan
|
|
14
|
+
never duplicates units. Idempotency's flip side: a kept ``done`` row silently
|
|
15
|
+
absorbs a re-planned unit with the same bounds, so a planner deliberately
|
|
16
|
+
RE-covering committed ground (the lookback margin — day-aligned units re-tile
|
|
17
|
+
onto identical keys every run) must first call ``release_done_units`` over the
|
|
18
|
+
window it is about to re-plan; the release-then-enqueue pairing at the single
|
|
19
|
+
planning site is what keeps re-covered days fetchable (DESIGN §5, corrected
|
|
20
|
+
2026-07-21). ``claim_next`` is a single atomic ``UPDATE ... WHERE
|
|
21
|
+
unit_id = (SELECT ... LIMIT 1) RETURNING ...`` — safe under concurrency because WAL
|
|
22
|
+
serializes writers, no app-level lock — that takes the lowest claimable ``unit_id``
|
|
23
|
+
(FIFO), increments ``attempt_count``, and clears the prior attempt's outcome.
|
|
24
|
+
Lifecycle: ``pending → claimed → done | failed``; ``failed`` units are re-served on
|
|
25
|
+
a later pass, unconditionally — there is no attempt cap (``attempt_count`` still
|
|
26
|
+
increments at claim, recorded and narrated, so a crash mid-execution counts). A
|
|
27
|
+
finite cap would convert a persistent failure into a silently skipped unit — a
|
|
28
|
+
coverage hole behind an advancing watermark — so a poison unit fails the endpoint
|
|
29
|
+
loudly every invocation instead, and the prefix-advance rule's gap-unreachability
|
|
30
|
+
argument leans on exactly this always-claimable property (DESIGN §5); a future
|
|
31
|
+
bounded retry policy must re-derive that argument before adding one.
|
|
32
|
+
|
|
33
|
+
Crash recovery is a startup reset: a single ``fleetpull`` invocation runs the whole
|
|
34
|
+
backfill as one process, so at startup any ``claimed`` row is stale (its worker is
|
|
35
|
+
gone) and ``reset_claimed_to_pending`` reverts it — no lease, no heartbeat (sound
|
|
36
|
+
only because two invocations never run against one state DB at once). An
|
|
37
|
+
unparseable stored chunk is state-store corruption and raises ``ConfigurationError``,
|
|
38
|
+
the same stance as the cursor store.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import logging
|
|
42
|
+
from collections.abc import Sequence
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from datetime import datetime
|
|
45
|
+
from enum import StrEnum
|
|
46
|
+
from typing import Final
|
|
47
|
+
|
|
48
|
+
from fleetpull.incremental import DateWindow
|
|
49
|
+
from fleetpull.state.database import (
|
|
50
|
+
SqliteScalar,
|
|
51
|
+
StateDatabase,
|
|
52
|
+
expect_int,
|
|
53
|
+
expect_text,
|
|
54
|
+
parse_stored_instant,
|
|
55
|
+
)
|
|
56
|
+
from fleetpull.timing import Clock, to_iso8601
|
|
57
|
+
from fleetpull.vocabulary import Provider
|
|
58
|
+
|
|
59
|
+
__all__: list[str] = [
|
|
60
|
+
'ClaimedWorkUnit',
|
|
61
|
+
'WorkUnitProgress',
|
|
62
|
+
'WorkUnitSpec',
|
|
63
|
+
'WorkUnitStatus',
|
|
64
|
+
'WorkUnitStore',
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WorkUnitStatus(StrEnum):
|
|
71
|
+
"""
|
|
72
|
+
The ``work_units.status`` lifecycle value.
|
|
73
|
+
|
|
74
|
+
``pending`` → ``claimed`` → ``done`` / ``failed``; a ``failed`` unit returns to
|
|
75
|
+
``claimed`` when re-served on a later claim. The store writes and filters
|
|
76
|
+
on this closed set, so centralizing the four literals keeps them out of
|
|
77
|
+
scattered string form. The values equal the schema CHECK literals exactly; the
|
|
78
|
+
two are held in two places by the same boundary discipline as ``CursorKind``
|
|
79
|
+
(the migration runner owns its DDL, the store owns its writes, neither imports
|
|
80
|
+
the other), pinned by the round-trip tests against a real migrated table.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
PENDING = 'pending'
|
|
84
|
+
CLAIMED = 'claimed'
|
|
85
|
+
DONE = 'done'
|
|
86
|
+
FAILED = 'failed'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class WorkUnitSpec:
|
|
91
|
+
"""
|
|
92
|
+
One backfill unit to enqueue: a date chunk of an endpoint, optionally partitioned.
|
|
93
|
+
|
|
94
|
+
Pure data, no validation — the ``chunk_start < chunk_end`` guard lives in
|
|
95
|
+
:meth:`WorkUnitStore.enqueue`, as with the ledger.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
provider: The provider being backfilled.
|
|
99
|
+
endpoint: The endpoint being backfilled.
|
|
100
|
+
partition_key: The opaque per-endpoint partition (a vehicle id, a driver
|
|
101
|
+
id, ...), or ``None`` for an unpartitioned endpoint. The store never
|
|
102
|
+
interprets it.
|
|
103
|
+
chunk_start: The chunk's inclusive start, timezone-aware UTC.
|
|
104
|
+
chunk_end: The chunk's end, timezone-aware UTC; must be after
|
|
105
|
+
``chunk_start``.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
provider: Provider
|
|
109
|
+
endpoint: str
|
|
110
|
+
partition_key: str | None
|
|
111
|
+
chunk_start: datetime
|
|
112
|
+
chunk_end: datetime
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class ClaimedWorkUnit:
|
|
117
|
+
"""
|
|
118
|
+
A unit handed to a worker by :meth:`WorkUnitStore.claim_next`.
|
|
119
|
+
|
|
120
|
+
Attributes:
|
|
121
|
+
unit_id: The claimed unit's id (the table's rowid alias).
|
|
122
|
+
spec: The unit's identity and chunk, reconstructed from the row.
|
|
123
|
+
attempt_count: The post-increment attempt number of the claim that
|
|
124
|
+
returned this unit (1 on the first claim).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
unit_id: int
|
|
128
|
+
spec: WorkUnitSpec
|
|
129
|
+
attempt_count: int
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class WorkUnitProgress:
|
|
134
|
+
"""
|
|
135
|
+
Per-status unit counts for one (provider, endpoint), from :meth:`WorkUnitStore.progress`.
|
|
136
|
+
|
|
137
|
+
Attributes:
|
|
138
|
+
pending: Units awaiting claim.
|
|
139
|
+
claimed: Units currently in flight.
|
|
140
|
+
done: Units completed successfully.
|
|
141
|
+
failed: Units that failed their last attempt (re-served on a later pass).
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
pending: int
|
|
145
|
+
claimed: int
|
|
146
|
+
done: int
|
|
147
|
+
failed: int
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_INSERT_UNIT_SQL: Final[str] = """
|
|
151
|
+
INSERT OR IGNORE INTO work_units (
|
|
152
|
+
provider, endpoint, partition_key, chunk_start, chunk_end
|
|
153
|
+
)
|
|
154
|
+
VALUES (?, ?, ?, ?, ?)
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
_RESET_CLAIMED_SQL: Final[str] = """
|
|
158
|
+
UPDATE work_units
|
|
159
|
+
SET status = ?
|
|
160
|
+
WHERE provider = ? AND endpoint = ? AND status = ?
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
# Lexical >= / <= on ``to_iso8601``'s fixed-width Z-form is chronological
|
|
164
|
+
# (the cursor store's recorded reasoning); only rows FULLY inside the
|
|
165
|
+
# window release -- a straddler stays done, and the re-plan's fresh tiles
|
|
166
|
+
# overlap it harmlessly (delete-by-window refetch is idempotent).
|
|
167
|
+
_RELEASE_DONE_SQL: Final[str] = """
|
|
168
|
+
DELETE FROM work_units
|
|
169
|
+
WHERE provider = ? AND endpoint = ? AND status = ?
|
|
170
|
+
AND chunk_start >= ? AND chunk_end <= ?
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
_CLAIM_NEXT_SQL: Final[str] = """
|
|
174
|
+
UPDATE work_units
|
|
175
|
+
SET status = ?, claimed_at = ?, attempt_count = attempt_count + 1,
|
|
176
|
+
finished_at = NULL, last_error = NULL
|
|
177
|
+
WHERE unit_id = (
|
|
178
|
+
SELECT unit_id FROM work_units
|
|
179
|
+
WHERE provider = ? AND endpoint = ? AND status IN (?, ?)
|
|
180
|
+
ORDER BY unit_id
|
|
181
|
+
LIMIT 1
|
|
182
|
+
)
|
|
183
|
+
RETURNING unit_id, partition_key, chunk_start, chunk_end, attempt_count
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
_MARK_DONE_SQL: Final[str] = """
|
|
187
|
+
UPDATE work_units
|
|
188
|
+
SET status = ?, finished_at = ?, observed_max = ?
|
|
189
|
+
WHERE unit_id = ? AND status = ?
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
# The prefix-advance read (DESIGN section 5, 2026-07-20): the maximum
|
|
193
|
+
# observation across the contiguous done-prefix -- every done unit whose
|
|
194
|
+
# chunk starts before the earliest not-done unit (or all done units when
|
|
195
|
+
# none remains). MAX over ``to_iso8601``'s fixed-width Z-form is
|
|
196
|
+
# chronological; NULL observations (empty units, pre-v3 completions) are
|
|
197
|
+
# ignored by MAX, and an all-NULL prefix returns NULL.
|
|
198
|
+
_DONE_PREFIX_OBSERVATION_SQL: Final[str] = """
|
|
199
|
+
SELECT MAX(observed_max) FROM work_units
|
|
200
|
+
WHERE provider = ? AND endpoint = ? AND status = ?
|
|
201
|
+
AND chunk_start < COALESCE(
|
|
202
|
+
(SELECT MIN(chunk_start) FROM work_units
|
|
203
|
+
WHERE provider = ? AND endpoint = ? AND status != ?),
|
|
204
|
+
'9999-12-31T23:59:59Z'
|
|
205
|
+
)
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
_MARK_FAILED_SQL: Final[str] = """
|
|
209
|
+
UPDATE work_units
|
|
210
|
+
SET status = ?, finished_at = ?, last_error = ?
|
|
211
|
+
WHERE unit_id = ? AND status = ?
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
_PROGRESS_SQL: Final[str] = """
|
|
215
|
+
SELECT status, count(*) FROM work_units
|
|
216
|
+
WHERE provider = ? AND endpoint = ?
|
|
217
|
+
GROUP BY status
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _build_claimed_unit(
|
|
222
|
+
provider: Provider, endpoint: str, row: tuple[SqliteScalar, ...]
|
|
223
|
+
) -> ClaimedWorkUnit:
|
|
224
|
+
"""
|
|
225
|
+
Reconstruct a :class:`ClaimedWorkUnit` from a claim's ``RETURNING`` row.
|
|
226
|
+
|
|
227
|
+
Narrows the row's columns to their STRICT types and parses the chunk bounds
|
|
228
|
+
back from ISO-8601 UTC. The ``spec`` reuses the claim call's ``provider`` /
|
|
229
|
+
``endpoint`` plus the row's ``partition_key`` and chunks.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
provider: The provider of the claim call (reused in the spec).
|
|
233
|
+
endpoint: The endpoint of the claim call (reused in the spec).
|
|
234
|
+
row: The ``RETURNING`` row: ``(unit_id, partition_key, chunk_start,
|
|
235
|
+
chunk_end, attempt_count)``.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The reconstructed claimed unit.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
ConfigurationError: A stored chunk bound is not parseable ISO-8601 UTC —
|
|
242
|
+
state-store corruption, the cursor-store stance.
|
|
243
|
+
RuntimeError: A column came back with a type the STRICT schema forbids — a
|
|
244
|
+
SQLite contract violation, surfaced loudly.
|
|
245
|
+
"""
|
|
246
|
+
raw_unit_id, raw_partition_key, raw_chunk_start, raw_chunk_end, raw_attempts = row
|
|
247
|
+
unit_id = expect_int(raw_unit_id, 'work_units.unit_id')
|
|
248
|
+
partition_key = (
|
|
249
|
+
None
|
|
250
|
+
if raw_partition_key is None
|
|
251
|
+
else expect_text(raw_partition_key, 'work_units.partition_key')
|
|
252
|
+
)
|
|
253
|
+
spec: WorkUnitSpec = WorkUnitSpec(
|
|
254
|
+
provider=provider,
|
|
255
|
+
endpoint=endpoint,
|
|
256
|
+
partition_key=partition_key,
|
|
257
|
+
chunk_start=parse_stored_instant(
|
|
258
|
+
expect_text(raw_chunk_start, 'work_units.chunk_start'),
|
|
259
|
+
provider=provider,
|
|
260
|
+
endpoint=endpoint,
|
|
261
|
+
column=f'work-unit chunk_start (unit {unit_id})',
|
|
262
|
+
),
|
|
263
|
+
chunk_end=parse_stored_instant(
|
|
264
|
+
expect_text(raw_chunk_end, 'work_units.chunk_end'),
|
|
265
|
+
provider=provider,
|
|
266
|
+
endpoint=endpoint,
|
|
267
|
+
column=f'work-unit chunk_end (unit {unit_id})',
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
return ClaimedWorkUnit(
|
|
271
|
+
unit_id=unit_id,
|
|
272
|
+
spec=spec,
|
|
273
|
+
attempt_count=expect_int(raw_attempts, 'work_units.attempt_count'),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class WorkUnitStore:
|
|
278
|
+
"""
|
|
279
|
+
The backfill claim queue: enqueue, atomic claim, completion, and crash recovery.
|
|
280
|
+
|
|
281
|
+
Dumb persistence plus an atomic claim (DESIGN §5). The caller plans the
|
|
282
|
+
decomposition and drives enqueue/claim/execute/complete; the store only stores
|
|
283
|
+
and serves units, knowing nothing about HTTP, parquet, chunking, or what a
|
|
284
|
+
``partition_key`` means. Runs after ``migrate_to_head`` (the ``work_units``
|
|
285
|
+
table must exist).
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
database: The initialized, migrated state database supplying connections.
|
|
289
|
+
clock: The clock stamping ``claimed_at`` and ``finished_at``.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
def __init__(self, database: StateDatabase, clock: Clock) -> None:
|
|
293
|
+
self._database: StateDatabase = database
|
|
294
|
+
self._clock: Clock = clock
|
|
295
|
+
|
|
296
|
+
def enqueue(self, units: Sequence[WorkUnitSpec]) -> int:
|
|
297
|
+
"""
|
|
298
|
+
Insert work units idempotently, returning the number newly inserted.
|
|
299
|
+
|
|
300
|
+
``INSERT OR IGNORE`` on the natural key (provider, endpoint, partition_key,
|
|
301
|
+
and the full window): re-enqueuing an existing unit inserts nothing, so a
|
|
302
|
+
re-run of a backfill plan never duplicates units. A same-start /
|
|
303
|
+
different-end unit is distinct (the store makes no overlap judgment — that
|
|
304
|
+
is the caller's concern).
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
units: The units to enqueue; each must have ``chunk_start`` strictly
|
|
308
|
+
before ``chunk_end``.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
The count of units actually inserted (existing ones are ignored).
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
ValueError: A unit has ``chunk_start`` not strictly before
|
|
315
|
+
``chunk_end``, or a chunk bound is naive or not UTC (surfaced from
|
|
316
|
+
the timing codec) — caller bugs, kept stdlib.
|
|
317
|
+
|
|
318
|
+
Side Effects:
|
|
319
|
+
Opens a connection, inserts the new rows, and commits.
|
|
320
|
+
"""
|
|
321
|
+
rows: list[tuple[str, str, str | None, str, str]] = []
|
|
322
|
+
for unit in units:
|
|
323
|
+
if unit.chunk_start >= unit.chunk_end:
|
|
324
|
+
raise ValueError('chunk_start must be strictly before chunk_end')
|
|
325
|
+
rows.append(
|
|
326
|
+
(
|
|
327
|
+
unit.provider.value,
|
|
328
|
+
unit.endpoint,
|
|
329
|
+
unit.partition_key,
|
|
330
|
+
to_iso8601(unit.chunk_start),
|
|
331
|
+
to_iso8601(unit.chunk_end),
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
with self._database.transaction() as connection:
|
|
335
|
+
changes_before: int = connection.total_changes
|
|
336
|
+
connection.executemany(_INSERT_UNIT_SQL, rows)
|
|
337
|
+
inserted: int = connection.total_changes - changes_before
|
|
338
|
+
logger.debug('enqueued %s work units (%s new)', len(rows), inserted)
|
|
339
|
+
return inserted
|
|
340
|
+
|
|
341
|
+
def release_done_units(
|
|
342
|
+
self, provider: Provider, endpoint: str, *, window: DateWindow
|
|
343
|
+
) -> int:
|
|
344
|
+
"""Delete ``done`` units lying fully inside a window about to be re-planned.
|
|
345
|
+
|
|
346
|
+
The planner's half of the release-then-enqueue pairing (module
|
|
347
|
+
docstring): a ``done`` row is committed history, and when the
|
|
348
|
+
planner deliberately re-covers its span (the lookback margin
|
|
349
|
+
refetch), the row must release back to plannable -- otherwise the
|
|
350
|
+
idempotent enqueue collapses onto it and the re-covered day is
|
|
351
|
+
never fetched again. Run provenance is the runs ledger's; a unit
|
|
352
|
+
row inside a window being re-planned is live work, not archive.
|
|
353
|
+
|
|
354
|
+
Sound at exactly one call site -- residual planning, which runs
|
|
355
|
+
only after the claim loop drained (so every row is ``done``) and
|
|
356
|
+
immediately re-tiles the released span hole-free, preserving the
|
|
357
|
+
prefix rule's gap-unreachability (DESIGN §5's re-derivation,
|
|
358
|
+
2026-07-21). A crash between release and enqueue is safe: the
|
|
359
|
+
watermark is untouched, so the next run resolves the identical
|
|
360
|
+
residual and plans it fresh. Only rows FULLY inside the window
|
|
361
|
+
release; a straddling ``done`` row stays, harmlessly overlapped
|
|
362
|
+
by the fresh tiles (refetch is idempotent per partition).
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
provider: The provider whose units to release.
|
|
366
|
+
endpoint: The endpoint whose units to release.
|
|
367
|
+
window: The residual window the caller is about to re-plan.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
The count of released (deleted) rows.
|
|
371
|
+
|
|
372
|
+
Side Effects:
|
|
373
|
+
Opens a connection, deletes the released rows, and commits.
|
|
374
|
+
"""
|
|
375
|
+
with self._database.transaction() as connection:
|
|
376
|
+
changes_before: int = connection.total_changes
|
|
377
|
+
connection.execute(
|
|
378
|
+
_RELEASE_DONE_SQL,
|
|
379
|
+
(
|
|
380
|
+
provider.value,
|
|
381
|
+
endpoint,
|
|
382
|
+
WorkUnitStatus.DONE.value,
|
|
383
|
+
to_iso8601(window.start),
|
|
384
|
+
to_iso8601(window.end),
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
released: int = connection.total_changes - changes_before
|
|
388
|
+
logger.debug('released %s done work units for re-planning', released)
|
|
389
|
+
return released
|
|
390
|
+
|
|
391
|
+
def reset_claimed_to_pending(self, provider: Provider, endpoint: str) -> int:
|
|
392
|
+
"""
|
|
393
|
+
Revert every ``claimed`` unit to ``pending`` (startup crash recovery).
|
|
394
|
+
|
|
395
|
+
Called once at backfill startup per endpoint: with no workers live yet, any
|
|
396
|
+
``claimed`` row is stale (its worker died in a prior crashed invocation), so
|
|
397
|
+
reverting it is safe — no lease, no heartbeat. ``attempt_count`` is left
|
|
398
|
+
untouched, so a crash-looping unit's attempts stay recorded. ``done`` /
|
|
399
|
+
``failed`` / ``pending`` rows are not touched.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
provider: The provider whose units to reset.
|
|
403
|
+
endpoint: The endpoint whose units to reset.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
The number of units reverted from ``claimed`` to ``pending``.
|
|
407
|
+
|
|
408
|
+
Side Effects:
|
|
409
|
+
Opens a connection, updates the matching rows, and commits.
|
|
410
|
+
"""
|
|
411
|
+
with self._database.transaction() as connection:
|
|
412
|
+
reset_count: int = connection.execute(
|
|
413
|
+
_RESET_CLAIMED_SQL,
|
|
414
|
+
(
|
|
415
|
+
WorkUnitStatus.PENDING.value,
|
|
416
|
+
provider.value,
|
|
417
|
+
endpoint,
|
|
418
|
+
WorkUnitStatus.CLAIMED.value,
|
|
419
|
+
),
|
|
420
|
+
).rowcount
|
|
421
|
+
logger.debug(
|
|
422
|
+
'reset %s claimed work units to pending: provider=%s endpoint=%s',
|
|
423
|
+
reset_count,
|
|
424
|
+
provider.value,
|
|
425
|
+
endpoint,
|
|
426
|
+
)
|
|
427
|
+
return reset_count
|
|
428
|
+
|
|
429
|
+
def claim_next(self, provider: Provider, endpoint: str) -> ClaimedWorkUnit | None:
|
|
430
|
+
"""
|
|
431
|
+
Atomically claim the lowest claimable unit, or return ``None``.
|
|
432
|
+
|
|
433
|
+
Claims the lowest-``unit_id`` (FIFO) ``pending`` or ``failed`` unit, in
|
|
434
|
+
one atomic ``UPDATE ... RETURNING`` that flips it to ``claimed``,
|
|
435
|
+
increments ``attempt_count`` (recorded and narrated; a crash
|
|
436
|
+
mid-execution still counts), and clears the prior attempt's
|
|
437
|
+
``finished_at`` / ``last_error``. There is deliberately no attempt cap
|
|
438
|
+
(the module docstring carries the fail-loud rationale). Safe under
|
|
439
|
+
concurrency without an app-level lock: WAL serializes writers, so a
|
|
440
|
+
competing claim's subquery re-evaluates only after this one commits.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
provider: The provider whose queue to claim from.
|
|
444
|
+
endpoint: The endpoint whose queue to claim from.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
The claimed unit, or ``None`` when nothing is claimable.
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
ConfigurationError: The claimed row holds an unparseable chunk —
|
|
451
|
+
state-store corruption.
|
|
452
|
+
RuntimeError: A claimed column came back with a forbidden type — a
|
|
453
|
+
SQLite contract violation.
|
|
454
|
+
|
|
455
|
+
Side Effects:
|
|
456
|
+
Opens a connection, claims one row, and commits.
|
|
457
|
+
"""
|
|
458
|
+
claimed_at: str = to_iso8601(self._clock.now_utc())
|
|
459
|
+
with self._database.transaction() as connection:
|
|
460
|
+
row = connection.execute(
|
|
461
|
+
_CLAIM_NEXT_SQL,
|
|
462
|
+
(
|
|
463
|
+
WorkUnitStatus.CLAIMED.value,
|
|
464
|
+
claimed_at,
|
|
465
|
+
provider.value,
|
|
466
|
+
endpoint,
|
|
467
|
+
WorkUnitStatus.PENDING.value,
|
|
468
|
+
WorkUnitStatus.FAILED.value,
|
|
469
|
+
),
|
|
470
|
+
).fetchone()
|
|
471
|
+
if row is None:
|
|
472
|
+
return None
|
|
473
|
+
claimed_unit: ClaimedWorkUnit = _build_claimed_unit(provider, endpoint, row)
|
|
474
|
+
logger.debug(
|
|
475
|
+
'claimed work unit: unit_id=%s attempt=%s',
|
|
476
|
+
claimed_unit.unit_id,
|
|
477
|
+
claimed_unit.attempt_count,
|
|
478
|
+
)
|
|
479
|
+
return claimed_unit
|
|
480
|
+
|
|
481
|
+
def mark_done(self, unit_id: int, *, observed_max: datetime | None) -> None:
|
|
482
|
+
"""
|
|
483
|
+
Mark a claimed unit ``done``, recording its folded observation.
|
|
484
|
+
|
|
485
|
+
Only a ``claimed`` unit may be completed; the guarded UPDATE matches no row
|
|
486
|
+
otherwise. ``observed_max`` is the unit's folded in-window maximum event
|
|
487
|
+
time — the prefix-advance watermark rule's datum
|
|
488
|
+
(``done_prefix_observation`` reads it); ``None`` records an empty unit,
|
|
489
|
+
which the prefix read ignores.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
unit_id: The claimed unit to complete.
|
|
493
|
+
observed_max: The unit's folded in-window maximum event time, or
|
|
494
|
+
``None`` when the unit observed no in-window event.
|
|
495
|
+
|
|
496
|
+
Raises:
|
|
497
|
+
ValueError: No ``claimed`` unit has this ``unit_id`` (it does not exist
|
|
498
|
+
or is not currently claimed) — a caller bug, kept stdlib; or a
|
|
499
|
+
naive / non-UTC ``observed_max`` (surfaced from the timing codec).
|
|
500
|
+
|
|
501
|
+
Side Effects:
|
|
502
|
+
Opens a connection, updates the row, and commits.
|
|
503
|
+
"""
|
|
504
|
+
finished_at: str = to_iso8601(self._clock.now_utc())
|
|
505
|
+
serialized_observation: str | None = (
|
|
506
|
+
to_iso8601(observed_max) if observed_max is not None else None
|
|
507
|
+
)
|
|
508
|
+
with self._database.transaction() as connection:
|
|
509
|
+
affected: int = connection.execute(
|
|
510
|
+
_MARK_DONE_SQL,
|
|
511
|
+
(
|
|
512
|
+
WorkUnitStatus.DONE.value,
|
|
513
|
+
finished_at,
|
|
514
|
+
serialized_observation,
|
|
515
|
+
unit_id,
|
|
516
|
+
WorkUnitStatus.CLAIMED.value,
|
|
517
|
+
),
|
|
518
|
+
).rowcount
|
|
519
|
+
if affected == 0:
|
|
520
|
+
raise ValueError(
|
|
521
|
+
f'no claimed work unit with unit_id {unit_id} to mark done'
|
|
522
|
+
)
|
|
523
|
+
logger.debug('marked work unit done: unit_id=%s', unit_id)
|
|
524
|
+
|
|
525
|
+
def done_prefix_observation(
|
|
526
|
+
self, provider: Provider, endpoint: str
|
|
527
|
+
) -> datetime | None:
|
|
528
|
+
"""
|
|
529
|
+
The maximum observation across the contiguous done-prefix.
|
|
530
|
+
|
|
531
|
+
The prefix-advance watermark rule's read (DESIGN §5, 2026-07-20): over
|
|
532
|
+
this endpoint's units ordered by ``chunk_start``, take every ``done``
|
|
533
|
+
unit before the earliest not-done one (all of them when none remains)
|
|
534
|
+
and return the maximum recorded ``observed_max``. Out-of-order parallel
|
|
535
|
+
completions beyond a gap contribute nothing until the gap closes, so a
|
|
536
|
+
watermark advanced to this value never overstates coverage. The query
|
|
537
|
+
is gap-blind by design: it sees only rows, so a hole no row represents
|
|
538
|
+
— a never-enqueued window between done units — would not gate the
|
|
539
|
+
prefix. Soundness therefore rests on the four §5 invariants (one
|
|
540
|
+
enqueue site running only after the claim loop drains, the capless
|
|
541
|
+
always-claimable queue, row deletion confined to the planning
|
|
542
|
+
site's release-then-enqueue pairing which immediately re-tiles the
|
|
543
|
+
released span, hole-free planner tiling), which make such a hole
|
|
544
|
+
unreachable; the state-layer gap-blindness test pins this
|
|
545
|
+
dependence.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
provider: The provider whose units to read.
|
|
549
|
+
endpoint: The endpoint whose units to read.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
The prefix's maximum observation, or ``None`` when the prefix is
|
|
553
|
+
empty or holds only empty units.
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
ConfigurationError: The stored observation is unparseable —
|
|
557
|
+
state-store corruption, the store's uniform stance.
|
|
558
|
+
RuntimeError: The observation column came back non-text,
|
|
559
|
+
violating the STRICT schema contract.
|
|
560
|
+
|
|
561
|
+
Side Effects:
|
|
562
|
+
Opens a connection and reads.
|
|
563
|
+
"""
|
|
564
|
+
done = WorkUnitStatus.DONE.value
|
|
565
|
+
with self._database.connect() as connection:
|
|
566
|
+
row: tuple[SqliteScalar] | None = connection.execute(
|
|
567
|
+
_DONE_PREFIX_OBSERVATION_SQL,
|
|
568
|
+
(provider.value, endpoint, done, provider.value, endpoint, done),
|
|
569
|
+
).fetchone()
|
|
570
|
+
observation = row[0] if row is not None else None
|
|
571
|
+
if observation is None:
|
|
572
|
+
return None
|
|
573
|
+
return parse_stored_instant(
|
|
574
|
+
expect_text(observation, 'work_units.observed_max'),
|
|
575
|
+
provider=provider,
|
|
576
|
+
endpoint=endpoint,
|
|
577
|
+
column='work-unit observed_max',
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def mark_failed(self, unit_id: int, *, error_detail: str) -> None:
|
|
581
|
+
"""
|
|
582
|
+
Mark a claimed unit ``failed`` with an error detail.
|
|
583
|
+
|
|
584
|
+
Only a ``claimed`` unit may be failed. ``attempt_count`` is not touched here
|
|
585
|
+
— it was already incremented at claim — so the attempt record stays true.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
unit_id: The claimed unit to fail.
|
|
589
|
+
error_detail: Human-readable failure context recorded on the row.
|
|
590
|
+
|
|
591
|
+
Raises:
|
|
592
|
+
ValueError: No ``claimed`` unit has this ``unit_id`` — a caller bug,
|
|
593
|
+
kept stdlib.
|
|
594
|
+
|
|
595
|
+
Side Effects:
|
|
596
|
+
Opens a connection, updates the row, and commits.
|
|
597
|
+
"""
|
|
598
|
+
finished_at: str = to_iso8601(self._clock.now_utc())
|
|
599
|
+
with self._database.transaction() as connection:
|
|
600
|
+
affected: int = connection.execute(
|
|
601
|
+
_MARK_FAILED_SQL,
|
|
602
|
+
(
|
|
603
|
+
WorkUnitStatus.FAILED.value,
|
|
604
|
+
finished_at,
|
|
605
|
+
error_detail,
|
|
606
|
+
unit_id,
|
|
607
|
+
WorkUnitStatus.CLAIMED.value,
|
|
608
|
+
),
|
|
609
|
+
).rowcount
|
|
610
|
+
if affected == 0:
|
|
611
|
+
raise ValueError(
|
|
612
|
+
f'no claimed work unit with unit_id {unit_id} to mark failed'
|
|
613
|
+
)
|
|
614
|
+
logger.debug('marked work unit failed: unit_id=%s', unit_id)
|
|
615
|
+
|
|
616
|
+
def progress(self, provider: Provider, endpoint: str) -> WorkUnitProgress:
|
|
617
|
+
"""
|
|
618
|
+
Return per-status unit counts for one (provider, endpoint).
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
provider: The provider whose units to count.
|
|
622
|
+
endpoint: The endpoint whose units to count.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
The counts, with statuses absent from the queue reported as ``0``.
|
|
626
|
+
|
|
627
|
+
Raises:
|
|
628
|
+
RuntimeError: A grouped ``status`` or count came back with a forbidden
|
|
629
|
+
type — a SQLite contract violation.
|
|
630
|
+
|
|
631
|
+
Side Effects:
|
|
632
|
+
Opens a connection and reads the grouped counts.
|
|
633
|
+
"""
|
|
634
|
+
with self._database.connect() as connection:
|
|
635
|
+
grouped_rows = connection.execute(
|
|
636
|
+
_PROGRESS_SQL, (provider.value, endpoint)
|
|
637
|
+
).fetchall()
|
|
638
|
+
counts: dict[str, int] = {}
|
|
639
|
+
for status_value, count_value in grouped_rows:
|
|
640
|
+
counts[expect_text(status_value, 'work_units.status')] = expect_int(
|
|
641
|
+
count_value, 'work-unit count'
|
|
642
|
+
)
|
|
643
|
+
return WorkUnitProgress(
|
|
644
|
+
pending=counts.get(WorkUnitStatus.PENDING.value, 0),
|
|
645
|
+
claimed=counts.get(WorkUnitStatus.CLAIMED.value, 0),
|
|
646
|
+
done=counts.get(WorkUnitStatus.DONE.value, 0),
|
|
647
|
+
failed=counts.get(WorkUnitStatus.FAILED.value, 0),
|
|
648
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# src/fleetpull/storage/__init__.py
|
|
2
|
+
"""The storage layer: write a records DataFrame to parquet.
|
|
3
|
+
|
|
4
|
+
``select_writer`` returns the ``DatasetWriter`` for an endpoint's storage-kind /
|
|
5
|
+
sync-mode cell; the orchestrator drives it (``write`` per piece, then
|
|
6
|
+
``finalize``). Stateless: parquet only, no SQLite and no watermark commit (the
|
|
7
|
+
orchestrator sequences those). ``WriteResult`` is the write report; ``in_window``
|
|
8
|
+
is the half-open ``[start, end)`` window-membership predicate the orchestrator
|
|
9
|
+
filters watermark batches with. ``MetadataSnapshot`` with its render/write pair
|
|
10
|
+
is the per-endpoint ``metadata.json`` projection the orchestrator writes after a
|
|
11
|
+
successful run (DESIGN §3)."""
|
|
12
|
+
|
|
13
|
+
from fleetpull.storage.frames import in_window
|
|
14
|
+
from fleetpull.storage.metadata import (
|
|
15
|
+
MetadataSnapshot,
|
|
16
|
+
render_metadata_json,
|
|
17
|
+
write_metadata_json,
|
|
18
|
+
)
|
|
19
|
+
from fleetpull.storage.result import WriteResult
|
|
20
|
+
from fleetpull.storage.writers import DatasetWriter, select_writer
|
|
21
|
+
|
|
22
|
+
__all__: list[str] = [
|
|
23
|
+
'DatasetWriter',
|
|
24
|
+
'MetadataSnapshot',
|
|
25
|
+
'WriteResult',
|
|
26
|
+
'in_window',
|
|
27
|
+
'render_metadata_json',
|
|
28
|
+
'select_writer',
|
|
29
|
+
'write_metadata_json',
|
|
30
|
+
]
|