keble-scraper-api 0.2.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.
- keble_scraper_api/__init__.py +3 -0
- keble_scraper_api/api/dependencies.py +100 -0
- keble_scraper_api/api/errors.py +72 -0
- keble_scraper_api/api/router.py +473 -0
- keble_scraper_api/api/schemas.py +26 -0
- keble_scraper_api/application/admin.py +285 -0
- keble_scraper_api/application/artifacts.py +393 -0
- keble_scraper_api/application/auth.py +324 -0
- keble_scraper_api/application/failures.py +427 -0
- keble_scraper_api/application/fetching.py +72 -0
- keble_scraper_api/application/jobs.py +891 -0
- keble_scraper_api/application/proxy_health.py +126 -0
- keble_scraper_api/application/scheduling.py +24 -0
- keble_scraper_api/artifact_namespace.py +76 -0
- keble_scraper_api/container.py +361 -0
- keble_scraper_api/errors.py +25 -0
- keble_scraper_api/infrastructure/artifact_store.py +585 -0
- keble_scraper_api/infrastructure/callbacks.py +285 -0
- keble_scraper_api/infrastructure/celery_factory.py +63 -0
- keble_scraper_api/infrastructure/cloudflare.py +259 -0
- keble_scraper_api/infrastructure/credentials.py +75 -0
- keble_scraper_api/infrastructure/dispatcher.py +57 -0
- keble_scraper_api/infrastructure/http_fetcher.py +351 -0
- keble_scraper_api/infrastructure/mongo.py +2345 -0
- keble_scraper_api/infrastructure/proxy_router.py +460 -0
- keble_scraper_api/infrastructure/request_profiles.py +220 -0
- keble_scraper_api/infrastructure/url_policy.py +341 -0
- keble_scraper_api/main.py +162 -0
- keble_scraper_api/maintenance/__init__.py +1 -0
- keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
- keble_scraper_api/models.py +473 -0
- keble_scraper_api/protocols.py +947 -0
- keble_scraper_api/settings.py +484 -0
- keble_scraper_api/telemetry.py +109 -0
- keble_scraper_api/workers/celery_app.py +48 -0
- keble_scraper_api/workers/cli.py +106 -0
- keble_scraper_api/workers/runtime.py +78 -0
- keble_scraper_api/workers/tasks.py +97 -0
- keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
- keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
- keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
- keble_scraper_api-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
"""Canonical job submission, execution, retry, cache, and audit services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from uuid import UUID, uuid4, uuid5
|
|
9
|
+
|
|
10
|
+
from keble_helpers import UpstreamFailureDisposition
|
|
11
|
+
from keble_scraper_contract import (
|
|
12
|
+
OperatorSession,
|
|
13
|
+
ScrapeArtifactRef,
|
|
14
|
+
ScrapeArtifactTrust,
|
|
15
|
+
ScrapeAttemptOutcome,
|
|
16
|
+
ScrapeAttemptView,
|
|
17
|
+
ScrapeCacheMode,
|
|
18
|
+
ScrapeCost,
|
|
19
|
+
ScrapeJobAccepted,
|
|
20
|
+
ScrapeJobCancel,
|
|
21
|
+
ScrapeJobCreate,
|
|
22
|
+
ScrapeLocalFailureCode,
|
|
23
|
+
ScrapeJobState,
|
|
24
|
+
ScrapeJobView,
|
|
25
|
+
ScrapeRetentionClass,
|
|
26
|
+
ScrapeTransportRoute,
|
|
27
|
+
utc_now,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from keble_scraper_api.application.artifacts import ArtifactService
|
|
31
|
+
from keble_scraper_api.application.fetching import FetchResult
|
|
32
|
+
from keble_scraper_api.application.failures import (
|
|
33
|
+
build_fetch_failure,
|
|
34
|
+
build_local_failure,
|
|
35
|
+
terminalize_failure,
|
|
36
|
+
)
|
|
37
|
+
from keble_scraper_api.application.scheduling import ceil_persistence_millisecond
|
|
38
|
+
from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
|
|
39
|
+
from keble_scraper_api.models import (
|
|
40
|
+
AuditAction,
|
|
41
|
+
DispatchableJob,
|
|
42
|
+
FetchLease,
|
|
43
|
+
RouteReservation,
|
|
44
|
+
RouteReservationDenied,
|
|
45
|
+
ScrapeAuditEventMongoObject,
|
|
46
|
+
ScrapeCacheEntryMongoObject,
|
|
47
|
+
build_completion_event,
|
|
48
|
+
)
|
|
49
|
+
from keble_scraper_api.protocols import (
|
|
50
|
+
AuditRepositoryProtocol,
|
|
51
|
+
BrowserFetcherProtocol,
|
|
52
|
+
CacheRepositoryProtocol,
|
|
53
|
+
HistoryRetentionRepositoryProtocol,
|
|
54
|
+
HttpFetcherProtocol,
|
|
55
|
+
JobDispatcherProtocol,
|
|
56
|
+
JobRepositoryProtocol,
|
|
57
|
+
OutboxRepositoryProtocol,
|
|
58
|
+
ProxyRouterProtocol,
|
|
59
|
+
RequestProfileProtocol,
|
|
60
|
+
)
|
|
61
|
+
from keble_scraper_api.settings import ScraperSettings
|
|
62
|
+
|
|
63
|
+
_ATTEMPT_NAMESPACE = UUID("d020a447-8384-4c34-a546-3f701ea524d7")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuditService:
|
|
67
|
+
"""Build immutable audit facts for every sensitive action.
|
|
68
|
+
|
|
69
|
+
Side effects if changes:
|
|
70
|
+
- compliance history for mutations and raw downloads.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, *, repository: AuditRepositoryProtocol) -> None:
|
|
74
|
+
"""Bind the immutable audit repository.
|
|
75
|
+
|
|
76
|
+
Side effects if changes:
|
|
77
|
+
- every API mutation writes through this instance.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
self._repository = repository
|
|
81
|
+
|
|
82
|
+
async def record(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
actor_id: str,
|
|
86
|
+
actor_role: str,
|
|
87
|
+
action: AuditAction,
|
|
88
|
+
target_identity: str,
|
|
89
|
+
reason: str,
|
|
90
|
+
request_id: str,
|
|
91
|
+
audit_id: UUID | None = None,
|
|
92
|
+
) -> UUID:
|
|
93
|
+
"""Append one server-timestamped audit event and return its identity.
|
|
94
|
+
|
|
95
|
+
Steps:
|
|
96
|
+
1. accept a reserved server identity when an external action must carry it;
|
|
97
|
+
2. otherwise create the immutable identity at this canonical boundary;
|
|
98
|
+
3. append the typed event before returning the persisted identity.
|
|
99
|
+
|
|
100
|
+
Side effects if changes:
|
|
101
|
+
- Mongo audit storage, investigations, and OSS-log correlation.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
resolved_audit_id = audit_id or uuid4()
|
|
105
|
+
await self._repository.append(
|
|
106
|
+
event=ScrapeAuditEventMongoObject(
|
|
107
|
+
audit_id=resolved_audit_id,
|
|
108
|
+
actor_id=actor_id,
|
|
109
|
+
actor_role=actor_role,
|
|
110
|
+
action=action,
|
|
111
|
+
target_identity=target_identity,
|
|
112
|
+
reason=reason,
|
|
113
|
+
request_id=request_id,
|
|
114
|
+
occurred_at=utc_now(),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return resolved_audit_id
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ScrapeJobService:
|
|
121
|
+
"""Own idempotent API submission/read/cancel behavior.
|
|
122
|
+
|
|
123
|
+
Side effects if changes:
|
|
124
|
+
- Mongo lifecycle, Celery dispatch, and mutation audit records.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
repository: JobRepositoryProtocol,
|
|
131
|
+
dispatcher: JobDispatcherProtocol,
|
|
132
|
+
profiles: RequestProfileProtocol,
|
|
133
|
+
audit: AuditService,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Bind canonical lifecycle dependencies without infrastructure lookup.
|
|
136
|
+
|
|
137
|
+
Side effects if changes:
|
|
138
|
+
- all service and operator job endpoints.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
self._repository = repository
|
|
142
|
+
self._dispatcher = dispatcher
|
|
143
|
+
self._profiles = profiles
|
|
144
|
+
self._audit = audit
|
|
145
|
+
|
|
146
|
+
async def create(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
caller_identity: str,
|
|
150
|
+
command: ScrapeJobCreate,
|
|
151
|
+
request_id: str,
|
|
152
|
+
actor_role: str = "SERVICE",
|
|
153
|
+
) -> ScrapeJobAccepted:
|
|
154
|
+
"""Create/reuse one job and dispatch only the newly inserted identity.
|
|
155
|
+
|
|
156
|
+
Side effects if changes:
|
|
157
|
+
- queue work, idempotency, and service audit history.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
prepared = self._profiles.prepare(command=command)
|
|
161
|
+
stored, reused = await self._repository.create_or_get(
|
|
162
|
+
caller_identity=caller_identity,
|
|
163
|
+
command=command,
|
|
164
|
+
request_hash=prepared.request_identity,
|
|
165
|
+
)
|
|
166
|
+
if not reused:
|
|
167
|
+
await self._dispatcher.dispatch(
|
|
168
|
+
job_id=stored.job_id,
|
|
169
|
+
transport_policy=command.transport_policy,
|
|
170
|
+
)
|
|
171
|
+
await self._audit.record(
|
|
172
|
+
actor_id=caller_identity,
|
|
173
|
+
actor_role=actor_role,
|
|
174
|
+
action=AuditAction.JOB_CREATE,
|
|
175
|
+
target_identity=str(stored.job_id),
|
|
176
|
+
reason="idempotent scrape job submission",
|
|
177
|
+
request_id=request_id,
|
|
178
|
+
)
|
|
179
|
+
return ScrapeJobAccepted(
|
|
180
|
+
job=await self._repository.get_view(job_id=stored.job_id),
|
|
181
|
+
reused=reused,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def get(self, *, job_id: UUID) -> ScrapeJobView:
|
|
185
|
+
"""Read one current job projection and attempt history.
|
|
186
|
+
|
|
187
|
+
Side effects if changes:
|
|
188
|
+
- service polling and operations UI.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
return await self._repository.get_view(job_id=job_id)
|
|
192
|
+
|
|
193
|
+
async def list_admin(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
limit: int,
|
|
197
|
+
before: datetime | None,
|
|
198
|
+
) -> tuple[ScrapeJobView, ...]:
|
|
199
|
+
"""List bounded newest jobs for authenticated operators.
|
|
200
|
+
|
|
201
|
+
Side effects if changes:
|
|
202
|
+
- dashboard Mongo read load.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
return await self._repository.list_views(limit=limit, before=before)
|
|
206
|
+
|
|
207
|
+
async def cancel(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
job_id: UUID,
|
|
211
|
+
command: ScrapeJobCancel,
|
|
212
|
+
actor: OperatorSession,
|
|
213
|
+
request_id: str,
|
|
214
|
+
) -> ScrapeJobView:
|
|
215
|
+
"""Authorize/commit optimistic cancellation and append audit evidence.
|
|
216
|
+
|
|
217
|
+
Side effects if changes:
|
|
218
|
+
- cancellation lifecycle and operator accountability.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
if not actor.can_mutate():
|
|
222
|
+
raise ScraperDomainError(
|
|
223
|
+
code=ScraperErrorCode.AUTH_FORBIDDEN,
|
|
224
|
+
message="operator role does not permit mutations",
|
|
225
|
+
)
|
|
226
|
+
view = await self._repository.cancel(job_id=job_id, command=command)
|
|
227
|
+
await self._audit.record(
|
|
228
|
+
actor_id=actor.user_id,
|
|
229
|
+
actor_role=actor.role.value,
|
|
230
|
+
action=AuditAction.JOB_CANCEL,
|
|
231
|
+
target_identity=str(job_id),
|
|
232
|
+
reason=command.reason,
|
|
233
|
+
request_id=request_id,
|
|
234
|
+
)
|
|
235
|
+
return view
|
|
236
|
+
|
|
237
|
+
async def cancel_service(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
job_id: UUID,
|
|
241
|
+
command: ScrapeJobCancel,
|
|
242
|
+
caller_identity: str,
|
|
243
|
+
request_id: str,
|
|
244
|
+
) -> ScrapeJobView:
|
|
245
|
+
"""Cancel a caller-owned job through the service credential boundary.
|
|
246
|
+
|
|
247
|
+
Side effects if changes:
|
|
248
|
+
- service cancellation lifecycle and audit identity.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
current = await self._repository.get_view(job_id=job_id)
|
|
252
|
+
if current.caller_identity != caller_identity:
|
|
253
|
+
raise ScraperDomainError(
|
|
254
|
+
code=ScraperErrorCode.AUTH_FORBIDDEN,
|
|
255
|
+
message="service caller does not own this job",
|
|
256
|
+
)
|
|
257
|
+
view = await self._repository.cancel(job_id=job_id, command=command)
|
|
258
|
+
await self._audit.record(
|
|
259
|
+
actor_id=caller_identity,
|
|
260
|
+
actor_role="SERVICE",
|
|
261
|
+
action=AuditAction.JOB_CANCEL,
|
|
262
|
+
target_identity=str(job_id),
|
|
263
|
+
reason=command.reason,
|
|
264
|
+
request_id=request_id,
|
|
265
|
+
)
|
|
266
|
+
return view
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class ScrapeExecutionService:
|
|
270
|
+
"""Execute leased jobs through cache, budget, fetch, artifact, and outbox stages.
|
|
271
|
+
|
|
272
|
+
Steps:
|
|
273
|
+
1. lease one durable immutable job budget;
|
|
274
|
+
2. resolve cache and route preflight before physical work;
|
|
275
|
+
3. append typed evidence and commit retry or terminal state atomically.
|
|
276
|
+
|
|
277
|
+
Side effects if changes:
|
|
278
|
+
- external network/vendor calls, Mongo attempts, OSS artifacts, and callbacks.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
settings: ScraperSettings,
|
|
285
|
+
jobs: JobRepositoryProtocol,
|
|
286
|
+
cache: CacheRepositoryProtocol,
|
|
287
|
+
artifacts: ArtifactService,
|
|
288
|
+
router: ProxyRouterProtocol,
|
|
289
|
+
profiles: RequestProfileProtocol,
|
|
290
|
+
http_fetcher: HttpFetcherProtocol,
|
|
291
|
+
browser_fetcher: BrowserFetcherProtocol,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Bind one worker dependency graph from typed components.
|
|
294
|
+
|
|
295
|
+
Side effects if changes:
|
|
296
|
+
- every fetch/render worker execution.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
self._settings = settings
|
|
300
|
+
self._jobs = jobs
|
|
301
|
+
self._cache = cache
|
|
302
|
+
self._artifacts = artifacts
|
|
303
|
+
self._router = router
|
|
304
|
+
self._profiles = profiles
|
|
305
|
+
self._http_fetcher = http_fetcher
|
|
306
|
+
self._browser_fetcher = browser_fetcher
|
|
307
|
+
|
|
308
|
+
async def process(self, *, job_id: UUID, worker_id: str) -> ScrapeJobView | None:
|
|
309
|
+
"""Lease and fully process one idempotent job identity.
|
|
310
|
+
|
|
311
|
+
Steps:
|
|
312
|
+
1. return without side effects when another worker/cancel owns the job;
|
|
313
|
+
2. reuse valid cache or reserve a priced/rate-bounded route;
|
|
314
|
+
3. append the fetch attempt before terminal/retry state transition;
|
|
315
|
+
4. commit artifacts and callback outbox with the job terminal state.
|
|
316
|
+
|
|
317
|
+
Side effects if changes:
|
|
318
|
+
- queue acknowledgement safety and all scraper external/persistent work.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
lease = await self._jobs.lease_due(
|
|
322
|
+
job_id=job_id,
|
|
323
|
+
worker_id=worker_id,
|
|
324
|
+
lease_seconds=self._settings.worker_lease_seconds,
|
|
325
|
+
)
|
|
326
|
+
if lease is None:
|
|
327
|
+
return None
|
|
328
|
+
command = lease.job.create
|
|
329
|
+
prepared = self._profiles.prepare(command=command)
|
|
330
|
+
cache_entry = await self._cache.get_valid(
|
|
331
|
+
request_hash=prepared.request_identity,
|
|
332
|
+
mode=command.cache_mode,
|
|
333
|
+
now=utc_now(),
|
|
334
|
+
)
|
|
335
|
+
if (
|
|
336
|
+
command.cache_mode is ScrapeCacheMode.REUSE_VALID
|
|
337
|
+
and cache_entry is not None
|
|
338
|
+
):
|
|
339
|
+
cached = await self._complete_from_cache(
|
|
340
|
+
lease=lease,
|
|
341
|
+
entry=cache_entry,
|
|
342
|
+
)
|
|
343
|
+
if cached is not None:
|
|
344
|
+
return cached
|
|
345
|
+
cache_entry = None
|
|
346
|
+
reservation_result = await self._router.reserve(
|
|
347
|
+
policy=command.transport_policy,
|
|
348
|
+
affinity_key=prepared.request_identity,
|
|
349
|
+
estimated_bytes=self._settings.response_policy(
|
|
350
|
+
command.policy_class.value
|
|
351
|
+
).max_bytes,
|
|
352
|
+
attempt_number=lease.attempt_number,
|
|
353
|
+
)
|
|
354
|
+
if isinstance(reservation_result, RouteReservationDenied):
|
|
355
|
+
return await self._record_preflight_failure(
|
|
356
|
+
lease=lease,
|
|
357
|
+
denial=reservation_result,
|
|
358
|
+
final_url=prepared.url,
|
|
359
|
+
)
|
|
360
|
+
reservation = reservation_result.reservation
|
|
361
|
+
conditionals = self._conditional_headers(entry=cache_entry)
|
|
362
|
+
response_policy = self._settings.response_policy(command.policy_class.value)
|
|
363
|
+
fetch_call = (
|
|
364
|
+
self._browser_fetcher.fetch(
|
|
365
|
+
request=prepared, response_policy=response_policy
|
|
366
|
+
)
|
|
367
|
+
if reservation.route is ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN
|
|
368
|
+
else self._http_fetcher.fetch(
|
|
369
|
+
request=prepared,
|
|
370
|
+
reservation=reservation,
|
|
371
|
+
response_policy=response_policy,
|
|
372
|
+
conditional_headers=conditionals,
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
fetched = await fetch_call
|
|
376
|
+
actual_cost = await self._router.reconcile(
|
|
377
|
+
reservation=reservation,
|
|
378
|
+
actual_bytes=len(fetched.body),
|
|
379
|
+
browser_seconds=float(fetched.browser_seconds),
|
|
380
|
+
)
|
|
381
|
+
attempt = self._attempt(
|
|
382
|
+
lease=lease,
|
|
383
|
+
result=fetched,
|
|
384
|
+
reservation=reservation,
|
|
385
|
+
actual_cost=actual_cost,
|
|
386
|
+
)
|
|
387
|
+
await self._jobs.append_attempt(job_id=job_id, attempt=attempt)
|
|
388
|
+
if fetched.succeeded:
|
|
389
|
+
return await self._commit_success(
|
|
390
|
+
lease=lease,
|
|
391
|
+
result=fetched,
|
|
392
|
+
cache_entry=cache_entry,
|
|
393
|
+
prepared_identity=prepared.request_identity,
|
|
394
|
+
)
|
|
395
|
+
await self._cache_negative(
|
|
396
|
+
result=fetched,
|
|
397
|
+
request_hash=prepared.request_identity,
|
|
398
|
+
)
|
|
399
|
+
return await self._commit_failure(lease=lease, result=fetched)
|
|
400
|
+
|
|
401
|
+
async def _complete_from_cache(
|
|
402
|
+
self,
|
|
403
|
+
*,
|
|
404
|
+
lease: FetchLease,
|
|
405
|
+
entry: ScrapeCacheEntryMongoObject,
|
|
406
|
+
) -> ScrapeJobView | None:
|
|
407
|
+
"""Complete a sound cache hit or invalidate one stale pointer.
|
|
408
|
+
|
|
409
|
+
Steps:
|
|
410
|
+
1. replay stable negative metadata without inventing provider usage;
|
|
411
|
+
2. resolve every positive artifact in one ordered batch read;
|
|
412
|
+
3. compare-and-delete a pointer with any missing artifact before fetching.
|
|
413
|
+
|
|
414
|
+
Side effects if changes:
|
|
415
|
+
- job terminal state and callback outbox receive only complete artifacts;
|
|
416
|
+
- stale cache entries cannot create lease-expiry retry loops.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
if entry.response_status >= 400:
|
|
420
|
+
synthetic = FetchResult(
|
|
421
|
+
outcome=ScrapeAttemptOutcome.HTTP_ERROR,
|
|
422
|
+
route=ScrapeTransportRoute.DIRECT,
|
|
423
|
+
trust=ScrapeArtifactTrust.VERIFIED,
|
|
424
|
+
final_url="https://cached.invalid/",
|
|
425
|
+
status_code=entry.response_status,
|
|
426
|
+
content_type=entry.content_type,
|
|
427
|
+
elapsed_ms=0,
|
|
428
|
+
error_code=f"CACHED_HTTP_{entry.response_status}",
|
|
429
|
+
observed_at=utc_now(),
|
|
430
|
+
)
|
|
431
|
+
return await self._commit_failure(
|
|
432
|
+
lease=lease, result=synthetic, force_terminal=True
|
|
433
|
+
)
|
|
434
|
+
lookup = await self._artifacts.get_many_existing(
|
|
435
|
+
artifact_ids=entry.artifact_ids,
|
|
436
|
+
)
|
|
437
|
+
if lookup.missing_artifact_ids:
|
|
438
|
+
await self._cache.delete_current(
|
|
439
|
+
request_hash=entry.request_hash,
|
|
440
|
+
updated_at=entry.updated_at,
|
|
441
|
+
)
|
|
442
|
+
return None
|
|
443
|
+
event = build_completion_event(
|
|
444
|
+
job=lease.job,
|
|
445
|
+
state=ScrapeJobState.SUCCEEDED,
|
|
446
|
+
artifacts=lookup.artifacts,
|
|
447
|
+
failure=None,
|
|
448
|
+
occurred_at=utc_now(),
|
|
449
|
+
)
|
|
450
|
+
return await self._jobs.complete(
|
|
451
|
+
lease=lease,
|
|
452
|
+
artifacts=lookup.artifacts,
|
|
453
|
+
event=event,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
async def _commit_success(
|
|
457
|
+
self,
|
|
458
|
+
*,
|
|
459
|
+
lease: FetchLease,
|
|
460
|
+
result: FetchResult,
|
|
461
|
+
cache_entry: ScrapeCacheEntryMongoObject | None,
|
|
462
|
+
prepared_identity: str,
|
|
463
|
+
) -> ScrapeJobView:
|
|
464
|
+
"""Capture/cache content and commit terminal success with outbox.
|
|
465
|
+
|
|
466
|
+
Steps:
|
|
467
|
+
1. validate every conditional-cache artifact before accepting a 304;
|
|
468
|
+
2. capture new content before publishing a retention-bounded cache pointer;
|
|
469
|
+
3. atomically commit success and its deterministic callback event.
|
|
470
|
+
|
|
471
|
+
Side effects if changes:
|
|
472
|
+
- OSS/Mongo artifacts, cache pointers, job state, and callback event.
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
if result.status_code == 304:
|
|
476
|
+
if cache_entry is None:
|
|
477
|
+
missing = result.model_copy(
|
|
478
|
+
update={
|
|
479
|
+
"outcome": ScrapeAttemptOutcome.HTTP_ERROR,
|
|
480
|
+
"error_code": "CACHE_VALIDATOR_WITHOUT_ENTRY",
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
return await self._commit_failure(
|
|
484
|
+
lease=lease, result=missing, force_terminal=True
|
|
485
|
+
)
|
|
486
|
+
lookup = await self._artifacts.get_many_existing(
|
|
487
|
+
artifact_ids=cache_entry.artifact_ids,
|
|
488
|
+
)
|
|
489
|
+
if lookup.missing_artifact_ids:
|
|
490
|
+
await self._cache.delete_current(
|
|
491
|
+
request_hash=cache_entry.request_hash,
|
|
492
|
+
updated_at=cache_entry.updated_at,
|
|
493
|
+
)
|
|
494
|
+
missing = result.model_copy(
|
|
495
|
+
update={
|
|
496
|
+
"outcome": ScrapeAttemptOutcome.HTTP_ERROR,
|
|
497
|
+
"error_code": "CACHE_VALIDATOR_WITHOUT_ENTRY",
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
return await self._commit_failure(
|
|
501
|
+
lease=lease,
|
|
502
|
+
result=missing,
|
|
503
|
+
)
|
|
504
|
+
artifacts = lookup.artifacts
|
|
505
|
+
else:
|
|
506
|
+
artifacts = await self._artifacts.capture(
|
|
507
|
+
result=result,
|
|
508
|
+
capture_mode=lease.job.create.capture_mode,
|
|
509
|
+
retention_class=lease.job.create.retention_class,
|
|
510
|
+
)
|
|
511
|
+
cache_updated_at = utc_now()
|
|
512
|
+
await self._cache.upsert(
|
|
513
|
+
entry=ScrapeCacheEntryMongoObject(
|
|
514
|
+
request_hash=prepared_identity,
|
|
515
|
+
artifact_ids=[item.artifact_id for item in artifacts],
|
|
516
|
+
etag=result.etag,
|
|
517
|
+
last_modified=result.last_modified,
|
|
518
|
+
response_status=result.status_code or 200,
|
|
519
|
+
content_type=result.content_type or "application/octet-stream",
|
|
520
|
+
expires_at=self._cache_expiry(
|
|
521
|
+
artifacts=artifacts,
|
|
522
|
+
retention_class=lease.job.create.retention_class,
|
|
523
|
+
now=cache_updated_at,
|
|
524
|
+
),
|
|
525
|
+
updated_at=cache_updated_at,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
event = build_completion_event(
|
|
529
|
+
job=lease.job,
|
|
530
|
+
state=ScrapeJobState.SUCCEEDED,
|
|
531
|
+
artifacts=artifacts,
|
|
532
|
+
failure=None,
|
|
533
|
+
occurred_at=utc_now(),
|
|
534
|
+
)
|
|
535
|
+
return await self._jobs.complete(
|
|
536
|
+
lease=lease,
|
|
537
|
+
artifacts=artifacts,
|
|
538
|
+
event=event,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
async def _commit_failure(
|
|
542
|
+
self,
|
|
543
|
+
*,
|
|
544
|
+
lease: FetchLease,
|
|
545
|
+
result: FetchResult,
|
|
546
|
+
force_terminal: bool = False,
|
|
547
|
+
) -> ScrapeJobView:
|
|
548
|
+
"""Apply bounded retry policy or terminal dead-letter with callback.
|
|
549
|
+
|
|
550
|
+
Steps:
|
|
551
|
+
1. classify the typed fetch result and compute local exponential backoff;
|
|
552
|
+
2. honor the later provider retry horizon without crossing the job deadline;
|
|
553
|
+
3. terminalize against immutable stored attempt/deadline policy and emit callback.
|
|
554
|
+
|
|
555
|
+
Side effects if changes:
|
|
556
|
+
- retry timing, vendor fallback, dead letters, and Shopify gap state.
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
failure = build_fetch_failure(result=result)
|
|
560
|
+
retryable = failure.disposition is UpstreamFailureDisposition.RETRYABLE
|
|
561
|
+
now = utc_now()
|
|
562
|
+
delay = self._settings.retry_base_seconds * (
|
|
563
|
+
2 ** max(0, lease.attempt_number - 1)
|
|
564
|
+
)
|
|
565
|
+
local_next_attempt_at = now + timedelta(seconds=delay)
|
|
566
|
+
next_attempt_at = ceil_persistence_millisecond(
|
|
567
|
+
value=max(
|
|
568
|
+
local_next_attempt_at,
|
|
569
|
+
failure.retry_at or local_next_attempt_at,
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
attempt_budget_exhausted = lease.attempt_number >= lease.job.attempt_limit
|
|
573
|
+
deadline_exhausted = (
|
|
574
|
+
lease.job.deadline_at <= now or next_attempt_at >= lease.job.deadline_at
|
|
575
|
+
)
|
|
576
|
+
dead_letter = (
|
|
577
|
+
force_terminal
|
|
578
|
+
or not retryable
|
|
579
|
+
or attempt_budget_exhausted
|
|
580
|
+
or deadline_exhausted
|
|
581
|
+
)
|
|
582
|
+
if deadline_exhausted and retryable and not force_terminal:
|
|
583
|
+
terminal_failure = build_local_failure(
|
|
584
|
+
code=ScrapeLocalFailureCode.JOB_DEADLINE_EXCEEDED,
|
|
585
|
+
safe_message="Scraper job deadline expired before safe retry.",
|
|
586
|
+
observed_at=now,
|
|
587
|
+
physical_attempt_count=failure.physical_attempt_count,
|
|
588
|
+
)
|
|
589
|
+
elif dead_letter:
|
|
590
|
+
terminal_failure = terminalize_failure(failure=failure)
|
|
591
|
+
else:
|
|
592
|
+
terminal_failure = failure
|
|
593
|
+
event = (
|
|
594
|
+
build_completion_event(
|
|
595
|
+
job=lease.job,
|
|
596
|
+
state=ScrapeJobState.DEAD_LETTER,
|
|
597
|
+
artifacts=(),
|
|
598
|
+
failure=terminal_failure,
|
|
599
|
+
occurred_at=now,
|
|
600
|
+
)
|
|
601
|
+
if dead_letter
|
|
602
|
+
else None
|
|
603
|
+
)
|
|
604
|
+
return await self._jobs.schedule_retry(
|
|
605
|
+
lease=lease,
|
|
606
|
+
failure=terminal_failure,
|
|
607
|
+
next_attempt_at=next_attempt_at,
|
|
608
|
+
dead_letter=dead_letter,
|
|
609
|
+
event=event,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
async def _record_preflight_failure(
|
|
613
|
+
self,
|
|
614
|
+
*,
|
|
615
|
+
lease: FetchLease,
|
|
616
|
+
denial: RouteReservationDenied,
|
|
617
|
+
final_url: str,
|
|
618
|
+
) -> ScrapeJobView:
|
|
619
|
+
"""Append an expected typed route/budget denial before retry policy.
|
|
620
|
+
|
|
621
|
+
Steps:
|
|
622
|
+
1. convert the finite preflight denial into a zero-physical-call fetch result;
|
|
623
|
+
2. append one immutable zero-cost attempt using the selected route;
|
|
624
|
+
3. retry only local capacity denial while terminalizing other policy denials.
|
|
625
|
+
|
|
626
|
+
Side effects if changes:
|
|
627
|
+
- pre-call attempts and budget-exhaustion retry behavior.
|
|
628
|
+
"""
|
|
629
|
+
|
|
630
|
+
result = FetchResult(
|
|
631
|
+
outcome=ScrapeAttemptOutcome.DENIED,
|
|
632
|
+
route=denial.route,
|
|
633
|
+
trust=(
|
|
634
|
+
ScrapeArtifactTrust.UNTRUSTED
|
|
635
|
+
if denial.route is ScrapeTransportRoute.PUBLIC_POOL_EXPERIMENT
|
|
636
|
+
else ScrapeArtifactTrust.VERIFIED
|
|
637
|
+
),
|
|
638
|
+
final_url=final_url,
|
|
639
|
+
elapsed_ms=0,
|
|
640
|
+
error_code=denial.code.value,
|
|
641
|
+
retry_at=denial.retry_at,
|
|
642
|
+
observed_at=utc_now(),
|
|
643
|
+
)
|
|
644
|
+
attempt = self._attempt(
|
|
645
|
+
lease=lease,
|
|
646
|
+
result=result,
|
|
647
|
+
reservation=RouteReservation(route=denial.route),
|
|
648
|
+
actual_cost=Decimal("0"),
|
|
649
|
+
)
|
|
650
|
+
await self._jobs.append_attempt(job_id=lease.job.job_id, attempt=attempt)
|
|
651
|
+
force_terminal = denial.code not in {
|
|
652
|
+
ScrapeLocalFailureCode.BUDGET_EXHAUSTED,
|
|
653
|
+
ScrapeLocalFailureCode.ROUTE_UNAVAILABLE,
|
|
654
|
+
}
|
|
655
|
+
return await self._commit_failure(
|
|
656
|
+
lease=lease,
|
|
657
|
+
result=result,
|
|
658
|
+
force_terminal=force_terminal,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
async def _cache_negative(self, *, result: FetchResult, request_hash: str) -> None:
|
|
662
|
+
"""Cache stable negative HTTP responses briefly without retaining bodies.
|
|
663
|
+
|
|
664
|
+
Side effects if changes:
|
|
665
|
+
- repeated missing/password/denied request traffic.
|
|
666
|
+
"""
|
|
667
|
+
|
|
668
|
+
if result.status_code not in {401, 403, 404, 410}:
|
|
669
|
+
return
|
|
670
|
+
await self._cache.upsert(
|
|
671
|
+
entry=ScrapeCacheEntryMongoObject(
|
|
672
|
+
request_hash=request_hash,
|
|
673
|
+
artifact_ids=[],
|
|
674
|
+
response_status=result.status_code,
|
|
675
|
+
content_type=result.content_type or "application/octet-stream",
|
|
676
|
+
expires_at=utc_now() + timedelta(hours=1),
|
|
677
|
+
updated_at=utc_now(),
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
@staticmethod
|
|
682
|
+
def _cache_expiry(
|
|
683
|
+
*,
|
|
684
|
+
artifacts: tuple[ScrapeArtifactRef, ...],
|
|
685
|
+
retention_class: ScrapeRetentionClass,
|
|
686
|
+
now: datetime,
|
|
687
|
+
) -> datetime:
|
|
688
|
+
"""Bound a cache pointer by both cache policy and artifact availability.
|
|
689
|
+
|
|
690
|
+
Steps:
|
|
691
|
+
1. derive the retention-class horizon for metadata-only captures;
|
|
692
|
+
2. cap reusable pointers at seven days;
|
|
693
|
+
3. select the earliest finite artifact expiry when artifacts exist.
|
|
694
|
+
|
|
695
|
+
Side effects if changes:
|
|
696
|
+
- cache reuse must never outlive the shortest referenced artifact;
|
|
697
|
+
- transient 24-hour captures cannot create six-day stale-pointer loops.
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
retention_expiry = {
|
|
701
|
+
ScrapeRetentionClass.TRANSIENT_24H: now + timedelta(hours=24),
|
|
702
|
+
ScrapeRetentionClass.CACHE_7D: now + timedelta(days=7),
|
|
703
|
+
ScrapeRetentionClass.REFERENCED_ARCHIVE: now + timedelta(days=7),
|
|
704
|
+
}[retention_class]
|
|
705
|
+
artifact_expiries = tuple(
|
|
706
|
+
artifact.expires_at
|
|
707
|
+
for artifact in artifacts
|
|
708
|
+
if artifact.expires_at is not None
|
|
709
|
+
)
|
|
710
|
+
return min((retention_expiry, *artifact_expiries))
|
|
711
|
+
|
|
712
|
+
def _attempt(
|
|
713
|
+
self,
|
|
714
|
+
*,
|
|
715
|
+
lease: FetchLease,
|
|
716
|
+
result: FetchResult,
|
|
717
|
+
reservation: RouteReservation,
|
|
718
|
+
actual_cost: Decimal,
|
|
719
|
+
) -> ScrapeAttemptView:
|
|
720
|
+
"""Convert one fetch result into immutable append-only attempt evidence.
|
|
721
|
+
|
|
722
|
+
Side effects if changes:
|
|
723
|
+
- job history, cost totals, and operational metrics.
|
|
724
|
+
"""
|
|
725
|
+
|
|
726
|
+
attempt_id = uuid5(
|
|
727
|
+
_ATTEMPT_NAMESPACE,
|
|
728
|
+
f"{lease.job.job_id}:{lease.attempt_number}",
|
|
729
|
+
)
|
|
730
|
+
return ScrapeAttemptView(
|
|
731
|
+
attempt_id=attempt_id,
|
|
732
|
+
attempt_number=lease.attempt_number,
|
|
733
|
+
route=result.route,
|
|
734
|
+
outcome=result.outcome,
|
|
735
|
+
trust=result.trust,
|
|
736
|
+
http_status=result.status_code,
|
|
737
|
+
bytes_received=len(result.body),
|
|
738
|
+
elapsed_ms=result.elapsed_ms,
|
|
739
|
+
failure=(None if result.succeeded else build_fetch_failure(result=result)),
|
|
740
|
+
cost=ScrapeCost(
|
|
741
|
+
estimated_amount=reservation.reserved_cost_usd,
|
|
742
|
+
actual_amount=actual_cost,
|
|
743
|
+
bytes_transferred=len(result.body),
|
|
744
|
+
browser_seconds=result.browser_seconds,
|
|
745
|
+
),
|
|
746
|
+
observed_at=result.observed_at,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
@staticmethod
|
|
750
|
+
def _conditional_headers(
|
|
751
|
+
*,
|
|
752
|
+
entry: ScrapeCacheEntryMongoObject | None,
|
|
753
|
+
) -> dict[str, str]:
|
|
754
|
+
"""Build only server-owned ETag/Last-Modified validators.
|
|
755
|
+
|
|
756
|
+
Side effects if changes:
|
|
757
|
+
- conditional request bytes and 304 cache reuse.
|
|
758
|
+
"""
|
|
759
|
+
|
|
760
|
+
if entry is None:
|
|
761
|
+
return {}
|
|
762
|
+
return {
|
|
763
|
+
key: value
|
|
764
|
+
for key, value in {
|
|
765
|
+
"If-None-Match": entry.etag,
|
|
766
|
+
"If-Modified-Since": entry.last_modified,
|
|
767
|
+
}.items()
|
|
768
|
+
if value is not None
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class MaintenanceService:
|
|
773
|
+
"""Run bounded cleanup, recovery, and due-job redispatch loops.
|
|
774
|
+
|
|
775
|
+
Steps:
|
|
776
|
+
1. recover expired job and callback leases under immutable budgets;
|
|
777
|
+
2. clean bounded cache/pin/orphan state in ownership order;
|
|
778
|
+
3. project and publish due jobs with bounded concurrency.
|
|
779
|
+
|
|
780
|
+
Side effects if changes:
|
|
781
|
+
- cache/pin/object retention and crashed-worker recovery.
|
|
782
|
+
"""
|
|
783
|
+
|
|
784
|
+
def __init__(
|
|
785
|
+
self,
|
|
786
|
+
*,
|
|
787
|
+
jobs: JobRepositoryProtocol,
|
|
788
|
+
outbox: OutboxRepositoryProtocol,
|
|
789
|
+
cache: CacheRepositoryProtocol,
|
|
790
|
+
artifacts: ArtifactService,
|
|
791
|
+
history: HistoryRetentionRepositoryProtocol,
|
|
792
|
+
dispatcher: JobDispatcherProtocol,
|
|
793
|
+
dispatch_concurrency: int,
|
|
794
|
+
history_retention_batch_size: int,
|
|
795
|
+
) -> None:
|
|
796
|
+
"""Bind maintenance repositories and bounded queue publication policy.
|
|
797
|
+
|
|
798
|
+
Steps:
|
|
799
|
+
1. retain separate job and callback recovery protocols;
|
|
800
|
+
2. retain cache/artifact and dependency-ordered history cleanup owners;
|
|
801
|
+
3. validate positive dispatch and history batch widths.
|
|
802
|
+
|
|
803
|
+
Side effects if changes:
|
|
804
|
+
- every beat-triggered maintenance run.
|
|
805
|
+
"""
|
|
806
|
+
|
|
807
|
+
if dispatch_concurrency < 1:
|
|
808
|
+
raise ValueError("dispatch_concurrency must be positive")
|
|
809
|
+
if history_retention_batch_size < 1:
|
|
810
|
+
raise ValueError("history retention batch size must be positive")
|
|
811
|
+
self._jobs = jobs
|
|
812
|
+
self._outbox = outbox
|
|
813
|
+
self._cache = cache
|
|
814
|
+
self._artifacts = artifacts
|
|
815
|
+
self._history = history
|
|
816
|
+
self._dispatcher = dispatcher
|
|
817
|
+
self._dispatch_concurrency = dispatch_concurrency
|
|
818
|
+
self._history_retention_batch_size = history_retention_batch_size
|
|
819
|
+
|
|
820
|
+
async def run(self) -> dict[str, int]:
|
|
821
|
+
"""Recover leases, evict cache/pins, delete orphans, and redispatch due jobs.
|
|
822
|
+
|
|
823
|
+
Steps:
|
|
824
|
+
1. recover/terminalize expired job and callback leases before new work;
|
|
825
|
+
2. clean expired cache, pins, orphan objects, and bounded history in owner order;
|
|
826
|
+
3. terminalize exhausted due jobs, then publish one bounded due projection batch.
|
|
827
|
+
|
|
828
|
+
Side effects if changes:
|
|
829
|
+
- Mongo/OSS deletions and Celery task publication.
|
|
830
|
+
"""
|
|
831
|
+
|
|
832
|
+
now = utc_now()
|
|
833
|
+
job_recovery = await self._jobs.recover_expired_leases(now=now)
|
|
834
|
+
callback_recovery = await self._outbox.recover_expired_callback_leases(
|
|
835
|
+
now=now,
|
|
836
|
+
)
|
|
837
|
+
cache_deleted = await self._cache.delete_expired(now=now)
|
|
838
|
+
pins_released = await self._artifacts.release_expired_pins()
|
|
839
|
+
orphans_deleted = await self._artifacts.cleanup_orphans()
|
|
840
|
+
history = await self._history.purge_expired_history(
|
|
841
|
+
now=now,
|
|
842
|
+
limit=self._history_retention_batch_size,
|
|
843
|
+
)
|
|
844
|
+
exhausted_due = await self._jobs.terminalize_exhausted_due_jobs(
|
|
845
|
+
now=now,
|
|
846
|
+
)
|
|
847
|
+
due = await self._jobs.due_jobs(now=now, limit=1_000)
|
|
848
|
+
await self._dispatch_due(due=due)
|
|
849
|
+
return {
|
|
850
|
+
"recoveredJobLeases": job_recovery.recovered_count,
|
|
851
|
+
"terminalizedJobLeases": job_recovery.terminalized_count,
|
|
852
|
+
"recoveredCallbackLeases": callback_recovery.recovered_count,
|
|
853
|
+
"terminalizedCallbackLeases": callback_recovery.terminalized_count,
|
|
854
|
+
"terminalizedDueJobs": exhausted_due,
|
|
855
|
+
"expiredCacheEntries": cache_deleted,
|
|
856
|
+
"releasedPins": pins_released,
|
|
857
|
+
"deletedOrphans": orphans_deleted,
|
|
858
|
+
"historyRetentionLeaseAcquired": int(history.lease_acquired),
|
|
859
|
+
"expiredJobs": history.jobs_deleted,
|
|
860
|
+
"expiredAttempts": history.attempts_deleted,
|
|
861
|
+
"expiredDeliveredCallbacks": history.delivered_outbox_deleted,
|
|
862
|
+
"expiredAuditEvents": history.audit_events_deleted,
|
|
863
|
+
"redispatchedJobs": len(due),
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async def _dispatch_due(
|
|
867
|
+
self,
|
|
868
|
+
*,
|
|
869
|
+
due: tuple[DispatchableJob, ...],
|
|
870
|
+
) -> None:
|
|
871
|
+
"""Publish due jobs in bounded fail-fast task groups.
|
|
872
|
+
|
|
873
|
+
Steps:
|
|
874
|
+
1. partition the minimal due projection by configured concurrency;
|
|
875
|
+
2. publish one partition through fail-fast structured concurrency;
|
|
876
|
+
3. start the next partition only after the current one succeeds.
|
|
877
|
+
|
|
878
|
+
Side effects if changes:
|
|
879
|
+
- Celery broker load and maintenance failure visibility.
|
|
880
|
+
"""
|
|
881
|
+
|
|
882
|
+
for offset in range(0, len(due), self._dispatch_concurrency):
|
|
883
|
+
batch = due[offset : offset + self._dispatch_concurrency]
|
|
884
|
+
async with asyncio.TaskGroup() as group:
|
|
885
|
+
for job in batch:
|
|
886
|
+
group.create_task(
|
|
887
|
+
self._dispatcher.dispatch(
|
|
888
|
+
job_id=job.job_id,
|
|
889
|
+
transport_policy=job.transport_policy,
|
|
890
|
+
)
|
|
891
|
+
)
|