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.
Files changed (42) hide show
  1. keble_scraper_api/__init__.py +3 -0
  2. keble_scraper_api/api/dependencies.py +100 -0
  3. keble_scraper_api/api/errors.py +72 -0
  4. keble_scraper_api/api/router.py +473 -0
  5. keble_scraper_api/api/schemas.py +26 -0
  6. keble_scraper_api/application/admin.py +285 -0
  7. keble_scraper_api/application/artifacts.py +393 -0
  8. keble_scraper_api/application/auth.py +324 -0
  9. keble_scraper_api/application/failures.py +427 -0
  10. keble_scraper_api/application/fetching.py +72 -0
  11. keble_scraper_api/application/jobs.py +891 -0
  12. keble_scraper_api/application/proxy_health.py +126 -0
  13. keble_scraper_api/application/scheduling.py +24 -0
  14. keble_scraper_api/artifact_namespace.py +76 -0
  15. keble_scraper_api/container.py +361 -0
  16. keble_scraper_api/errors.py +25 -0
  17. keble_scraper_api/infrastructure/artifact_store.py +585 -0
  18. keble_scraper_api/infrastructure/callbacks.py +285 -0
  19. keble_scraper_api/infrastructure/celery_factory.py +63 -0
  20. keble_scraper_api/infrastructure/cloudflare.py +259 -0
  21. keble_scraper_api/infrastructure/credentials.py +75 -0
  22. keble_scraper_api/infrastructure/dispatcher.py +57 -0
  23. keble_scraper_api/infrastructure/http_fetcher.py +351 -0
  24. keble_scraper_api/infrastructure/mongo.py +2345 -0
  25. keble_scraper_api/infrastructure/proxy_router.py +460 -0
  26. keble_scraper_api/infrastructure/request_profiles.py +220 -0
  27. keble_scraper_api/infrastructure/url_policy.py +341 -0
  28. keble_scraper_api/main.py +162 -0
  29. keble_scraper_api/maintenance/__init__.py +1 -0
  30. keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
  31. keble_scraper_api/models.py +473 -0
  32. keble_scraper_api/protocols.py +947 -0
  33. keble_scraper_api/settings.py +484 -0
  34. keble_scraper_api/telemetry.py +109 -0
  35. keble_scraper_api/workers/celery_app.py +48 -0
  36. keble_scraper_api/workers/cli.py +106 -0
  37. keble_scraper_api/workers/runtime.py +78 -0
  38. keble_scraper_api/workers/tasks.py +97 -0
  39. keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
  40. keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
  41. keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
  42. keble_scraper_api-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,2345 @@
1
+ """Mongo persistence, indexes, transactions, and structural conversions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Mapping, Sequence
7
+ from datetime import UTC, datetime, timedelta
8
+ from decimal import Decimal
9
+ import re
10
+ from typing import Any
11
+ from uuid import UUID, uuid4, uuid5
12
+
13
+ from bson.codec_options import CodecOptions, TypeCodec, TypeRegistry
14
+ from bson.binary import UuidRepresentation
15
+ from bson.decimal128 import Decimal128
16
+ from pydantic import SecretStr
17
+ from pymongo import (
18
+ ASCENDING,
19
+ DESCENDING,
20
+ AsyncMongoClient,
21
+ IndexModel,
22
+ ReturnDocument,
23
+ )
24
+ from pymongo.read_concern import ReadConcern
25
+ from pymongo.write_concern import WriteConcern
26
+
27
+ from keble_scraper_contract import (
28
+ ProxyCandidateCreate,
29
+ ProxyCandidateState,
30
+ ProxyCandidateView,
31
+ ProxyHealthObservation,
32
+ ScrapeArtifactPin,
33
+ ScrapeArtifactRef,
34
+ ScrapeArtifactTrust,
35
+ ScrapeAttemptView,
36
+ ScrapeCacheMode,
37
+ ScrapeCompletionEvent,
38
+ ScrapeFailure,
39
+ ScrapeJobCancel,
40
+ ScrapeJobCreate,
41
+ ScrapeJobState,
42
+ ScrapeJobView,
43
+ ScrapeRetentionClass,
44
+ ScrapeTransportPolicy,
45
+ ScrapeLocalFailureCode,
46
+ utc_now,
47
+ )
48
+
49
+ from keble_scraper_api.artifact_namespace import ArtifactStoreIdentity
50
+ from keble_scraper_api.application.failures import build_local_failure
51
+ from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
52
+ from keble_scraper_api.models import (
53
+ ArtifactBatchLookup,
54
+ DispatchableJob,
55
+ FetchLease,
56
+ HistoryRetentionSummary,
57
+ LeaseRecoverySummary,
58
+ OutboxState,
59
+ ProxyCandidateMongoObject,
60
+ ScrapeArtifactMongoObject,
61
+ ScrapeArtifactPinMongoObject,
62
+ ScrapeAttemptMongoObject,
63
+ ScrapeAuditEventMongoObject,
64
+ ScrapeCacheEntryMongoObject,
65
+ ScrapeJobMongoObject,
66
+ ScrapeOutboxMongoObject,
67
+ build_completion_event,
68
+ )
69
+ from keble_scraper_api.settings import ScraperSettings
70
+
71
+ from .credentials import ProxyCredentialCipher
72
+
73
+ _JOB_NAMESPACE = UUID("99034655-a296-4a6a-aa9d-69e17998ec15")
74
+ _PROXY_NAMESPACE = UUID("b6404416-e7d4-4481-8b34-7484f5a10fab")
75
+ _PIN_NAMESPACE = UUID("5c190785-8648-44ac-89e2-a413707f8058")
76
+ _ARTIFACT_NAMESPACE_MARKER_ID = "artifact-store-namespace-v1"
77
+ _ARTIFACT_NAMESPACE_PROBE_HASH = "0" * 64
78
+ _HISTORY_RETENTION_LEASE_ID = "history-retention-v1"
79
+
80
+
81
+ class _DecimalCodec(TypeCodec):
82
+ """Round-trip exact Python Decimal values through BSON Decimal128.
83
+
84
+ Side effects if changes:
85
+ - vendor cost arithmetic and budget/audit persistence precision.
86
+ """
87
+
88
+ @property
89
+ def python_type(self) -> type[Decimal]:
90
+ """Return the exact application type encoded by this codec.
91
+
92
+ Side effects if changes:
93
+ - Mongo type-registry selection for every Decimal value.
94
+ """
95
+
96
+ return Decimal
97
+
98
+ @property
99
+ def bson_type(self) -> type[Decimal128]:
100
+ """Return the exact BSON type decoded by this codec.
101
+
102
+ Side effects if changes:
103
+ - Mongo type-registry decoding for every Decimal128 value.
104
+ """
105
+
106
+ return Decimal128
107
+
108
+ def transform_python(self, value: Decimal) -> Decimal128:
109
+ """Encode one exact decimal before Mongo writes.
110
+
111
+ Side effects if changes:
112
+ - all embedded `ScrapeCost` values written by this repository.
113
+ """
114
+
115
+ return Decimal128(value)
116
+
117
+ def transform_bson(self, value: Decimal128) -> Decimal:
118
+ """Decode one BSON decimal into Pydantic-compatible Decimal.
119
+
120
+ Side effects if changes:
121
+ - job cost views and attempt history validation.
122
+ """
123
+
124
+ return value.to_decimal()
125
+
126
+
127
+ SCRAPER_INDEXES: dict[str, tuple[IndexModel, ...]] = {
128
+ "scrape_jobs": (
129
+ IndexModel(
130
+ [("caller_identity", ASCENDING), ("create.idempotency_key", ASCENDING)],
131
+ unique=True,
132
+ name="caller_idempotency_unique_idx",
133
+ ),
134
+ IndexModel(
135
+ [("state", ASCENDING), ("next_attempt_at", ASCENDING)],
136
+ name="state_next_attempt_idx",
137
+ ),
138
+ IndexModel(
139
+ [("state", ASCENDING), ("lease_expires_at", ASCENDING)],
140
+ name="state_lease_expiry_idx",
141
+ ),
142
+ IndexModel([("created_at", DESCENDING)], name="created_at_desc_idx"),
143
+ IndexModel(
144
+ [("history_expires_at", ASCENDING)],
145
+ name="history_expires_at_idx",
146
+ ),
147
+ ),
148
+ "scrape_attempts": (
149
+ IndexModel(
150
+ [("job_id", ASCENDING), ("attempt.attempt_number", ASCENDING)],
151
+ unique=True,
152
+ name="job_attempt_number_unique_idx",
153
+ ),
154
+ IndexModel(
155
+ [("job_id", ASCENDING), ("attempt.observed_at", DESCENDING)],
156
+ name="job_observed_at_desc_idx",
157
+ ),
158
+ IndexModel(
159
+ [("attempt.route", ASCENDING), ("attempt.observed_at", DESCENDING)],
160
+ name="route_observed_at_desc_idx",
161
+ ),
162
+ ),
163
+ "scrape_cache_entries": (
164
+ IndexModel(
165
+ [("request_hash", ASCENDING)], unique=True, name="request_hash_unique_idx"
166
+ ),
167
+ IndexModel([("expires_at", ASCENDING)], name="expires_at_idx"),
168
+ ),
169
+ "scrape_artifacts": (
170
+ IndexModel(
171
+ [("artifact.content_sha256", ASCENDING), ("artifact.kind", ASCENDING)],
172
+ unique=True,
173
+ name="content_kind_unique_idx",
174
+ ),
175
+ IndexModel(
176
+ [
177
+ ("reference_count", ASCENDING),
178
+ ("artifact.expires_at", ASCENDING),
179
+ ("deletion_lease_expires_at", ASCENDING),
180
+ ],
181
+ name="orphan_expiry_idx",
182
+ ),
183
+ ),
184
+ "scrape_artifact_pins": (
185
+ IndexModel(
186
+ [("artifact_id", ASCENDING), ("owner_identity", ASCENDING)],
187
+ unique=True,
188
+ name="artifact_owner_unique_idx",
189
+ ),
190
+ IndexModel([("expires_at", ASCENDING)], name="expires_at_idx"),
191
+ ),
192
+ "proxy_candidates": (
193
+ IndexModel(
194
+ [("endpoint_redacted", ASCENDING)], unique=True, name="endpoint_unique_idx"
195
+ ),
196
+ IndexModel(
197
+ [("state", ASCENDING), ("health_score", DESCENDING)],
198
+ name="state_health_desc_idx",
199
+ ),
200
+ IndexModel(
201
+ [("state", ASCENDING), ("next_health_at", ASCENDING)],
202
+ name="state_next_health_idx",
203
+ ),
204
+ IndexModel(
205
+ [("health_score", DESCENDING), ("created_at", DESCENDING)],
206
+ name="health_created_desc_idx",
207
+ ),
208
+ ),
209
+ "scrape_outbox": (
210
+ IndexModel(
211
+ [("event.event_id", ASCENDING)], unique=True, name="event_unique_idx"
212
+ ),
213
+ IndexModel(
214
+ [("state", ASCENDING), ("next_attempt_at", ASCENDING)],
215
+ name="state_next_attempt_idx",
216
+ ),
217
+ IndexModel(
218
+ [("state", ASCENDING), ("lease_expires_at", ASCENDING)],
219
+ name="state_lease_expiry_idx",
220
+ ),
221
+ IndexModel(
222
+ [("history_expires_at", ASCENDING)],
223
+ name="history_expires_at_idx",
224
+ ),
225
+ ),
226
+ "scrape_audit_events": (
227
+ IndexModel(
228
+ [("history_expires_at", ASCENDING)],
229
+ name="history_expires_at_idx",
230
+ ),
231
+ ),
232
+ }
233
+
234
+ OBSOLETE_SCRAPER_INDEX_NAMES: dict[str, tuple[str, ...]] = {
235
+ "scrape_audit_events": (
236
+ "actor_occurred_at_desc_idx",
237
+ "action_occurred_at_desc_idx",
238
+ "target_text_idx",
239
+ ),
240
+ }
241
+
242
+
243
+ class MongoScraperRepository:
244
+ """Implement scraper persistence through indexed Mongo query shapes.
245
+
246
+ Steps:
247
+ 1. own all typed scraper collections and their indexes;
248
+ 2. fence lifecycle transitions with transactions and lease ownership;
249
+ 3. convert every persisted document through strict Pydantic models.
250
+
251
+ Side effects if changes:
252
+ - all job/cache/artifact/proxy/outbox/audit persistence and transactions.
253
+ """
254
+
255
+ def __init__(
256
+ self,
257
+ *,
258
+ client: AsyncMongoClient[dict[str, Any]],
259
+ settings: ScraperSettings,
260
+ proxy_cipher: ProxyCredentialCipher,
261
+ ) -> None:
262
+ """Bind one client, database ownership, and proxy secret cipher.
263
+
264
+ Steps:
265
+ 1. configure timezone/UUID/Decimal codecs and majority writes;
266
+ 2. retain immutable job attempt/deadline creation policy;
267
+ 3. bind every owned collection by stable name.
268
+
269
+ Side effects if changes:
270
+ - database selection and collection/index ownership.
271
+ """
272
+
273
+ codec_options = CodecOptions(
274
+ type_registry=TypeRegistry([_DecimalCodec()]),
275
+ tz_aware=True,
276
+ tzinfo=UTC,
277
+ uuid_representation=UuidRepresentation.STANDARD,
278
+ )
279
+ self._client = client
280
+ self._database = client.get_database(
281
+ settings.mongo_database,
282
+ codec_options=codec_options,
283
+ write_concern=WriteConcern("majority"),
284
+ )
285
+ self._cipher = proxy_cipher
286
+ self._attempt_limit = settings.max_attempts
287
+ self._job_deadline_seconds = settings.job_deadline_seconds
288
+ self._terminal_history_retention = timedelta(
289
+ days=settings.terminal_history_retention_days
290
+ )
291
+ self._delivered_outbox_retention = timedelta(
292
+ days=settings.delivered_outbox_retention_days
293
+ )
294
+ self._audit_history_retention = timedelta(
295
+ days=settings.audit_history_retention_days
296
+ )
297
+ self._history_retention_lease = timedelta(
298
+ seconds=settings.history_retention_lease_seconds
299
+ )
300
+ self.jobs = self._database["scrape_jobs"]
301
+ self.attempts = self._database["scrape_attempts"]
302
+ self.cache = self._database["scrape_cache_entries"]
303
+ self.artifacts = self._database["scrape_artifacts"]
304
+ self.artifact_pins = self._database["scrape_artifact_pins"]
305
+ self.metadata = self._database["scrape_metadata"]
306
+ self.proxies = self._database["proxy_candidates"]
307
+ self.outbox = self._database["scrape_outbox"]
308
+ self.audit = self._database["scrape_audit_events"]
309
+
310
+ @classmethod
311
+ def build(
312
+ cls,
313
+ *,
314
+ settings: ScraperSettings,
315
+ proxy_cipher: ProxyCredentialCipher,
316
+ ) -> "MongoScraperRepository":
317
+ """Construct a standard-UUID async Mongo client from typed settings.
318
+
319
+ Side effects if changes:
320
+ - process connection pools and replica-set transaction behavior.
321
+ """
322
+
323
+ client: AsyncMongoClient[dict[str, Any]] = AsyncMongoClient(
324
+ settings.mongo_uri.get_secret_value(),
325
+ uuidRepresentation="standard",
326
+ tz_aware=True,
327
+ )
328
+ return cls(client=client, settings=settings, proxy_cipher=proxy_cipher)
329
+
330
+ async def ensure_indexes(self) -> None:
331
+ """Create live indexes and remove indexes with no supported read surface.
332
+
333
+ Steps:
334
+ 1. inspect each owned collection's exact index names;
335
+ 2. remove only explicitly obsolete deterministic names;
336
+ 3. create the current non-empty index set idempotently.
337
+
338
+ Side effects if changes:
339
+ - startup readiness and every heavy list/lease/cleanup query.
340
+ """
341
+
342
+ collections = {
343
+ "scrape_jobs": self.jobs,
344
+ "scrape_attempts": self.attempts,
345
+ "scrape_cache_entries": self.cache,
346
+ "scrape_artifacts": self.artifacts,
347
+ "scrape_artifact_pins": self.artifact_pins,
348
+ "proxy_candidates": self.proxies,
349
+ "scrape_outbox": self.outbox,
350
+ "scrape_audit_events": self.audit,
351
+ }
352
+ for name, collection in collections.items():
353
+ existing = await collection.index_information()
354
+ for obsolete_name in OBSOLETE_SCRAPER_INDEX_NAMES.get(name, ()):
355
+ if obsolete_name in existing:
356
+ await collection.drop_index(obsolete_name)
357
+ indexes = SCRAPER_INDEXES[name]
358
+ if indexes:
359
+ await collection.create_indexes(list(indexes))
360
+ await self.metadata.update_one(
361
+ {"_id": _HISTORY_RETENTION_LEASE_ID},
362
+ {
363
+ "$setOnInsert": {
364
+ "lease_owner": None,
365
+ "lease_expires_at": datetime.fromtimestamp(0, tz=UTC),
366
+ "fence_revision": 0,
367
+ }
368
+ },
369
+ upsert=True,
370
+ )
371
+
372
+ async def close(self) -> None:
373
+ """Close the async Mongo pool during application shutdown.
374
+
375
+ Side effects if changes:
376
+ - API/worker resource cleanup.
377
+ """
378
+
379
+ await self._client.close()
380
+
381
+ async def readiness(self) -> None:
382
+ """Ping Mongo for readiness without exposing topology details.
383
+
384
+ Side effects if changes:
385
+ - deployment health and rollout gating.
386
+ """
387
+
388
+ await self._client.admin.command("ping")
389
+
390
+ async def ensure_artifact_store_namespace(
391
+ self,
392
+ *,
393
+ probe_object_key: str,
394
+ store_identity: ArtifactStoreIdentity,
395
+ namespace_owner: str,
396
+ ) -> None:
397
+ """Bind artifact metadata to one complete backend/location identity.
398
+
399
+ Steps:
400
+ 1. validate the store's deterministic zero-hash probe shape;
401
+ 2. reject legacy/unmarked rows and keys outside the configured prefix;
402
+ 3. atomically retain backend, endpoint/bucket or directory, prefix, and owner;
403
+ 4. reject every later backend/location/owner drift before serving work.
404
+
405
+ Side effects if changes:
406
+ - API/worker startup fails before reads/writes when store wiring drifts;
407
+ - one artifact identity cannot resolve to two backend object keys.
408
+ """
409
+
410
+ parts = probe_object_key.rsplit("/", 2)
411
+ if len(parts) != 3 or parts[1:] != ["00", _ARTIFACT_NAMESPACE_PROBE_HASH]:
412
+ raise ValueError(
413
+ "artifact store probe key must end with the zero hash path"
414
+ )
415
+ namespace_prefix = parts[0]
416
+ if store_identity.prefix != namespace_prefix:
417
+ raise ScraperDomainError(
418
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
419
+ message="artifact store identity prefix differs from its probe key",
420
+ )
421
+ store_identity_payload = store_identity.model_dump(mode="json")
422
+ marker = await self.metadata.find_one({"_id": _ARTIFACT_NAMESPACE_MARKER_ID})
423
+ if marker is not None:
424
+ if (
425
+ marker.get("probe_object_key") != probe_object_key
426
+ or marker.get("store_identity") != store_identity_payload
427
+ or marker.get("namespace_owner") != namespace_owner
428
+ ):
429
+ raise ScraperDomainError(
430
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
431
+ message=(
432
+ "artifact store backend, location, or owner changed for this database"
433
+ ),
434
+ )
435
+ return
436
+ mismatch = await self.artifacts.find_one(
437
+ {"object_key": {"$not": re.compile(f"^{re.escape(namespace_prefix)}/")}},
438
+ {"_id": 1},
439
+ )
440
+ if mismatch is not None:
441
+ raise ScraperDomainError(
442
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
443
+ message="artifact store namespace differs from persisted artifact keys",
444
+ )
445
+ existing_artifact = await self.artifacts.find_one({}, {"_id": 1})
446
+ if existing_artifact is not None:
447
+ raise ScraperDomainError(
448
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
449
+ message="artifact store identity is missing for persisted artifact rows",
450
+ )
451
+ await self.metadata.update_one(
452
+ {"_id": _ARTIFACT_NAMESPACE_MARKER_ID},
453
+ {
454
+ "$setOnInsert": {
455
+ "probe_object_key": probe_object_key,
456
+ "store_identity": store_identity_payload,
457
+ "namespace_owner": namespace_owner,
458
+ }
459
+ },
460
+ upsert=True,
461
+ )
462
+ marker = await self.metadata.find_one({"_id": _ARTIFACT_NAMESPACE_MARKER_ID})
463
+ if (
464
+ marker is None
465
+ or marker.get("probe_object_key") != probe_object_key
466
+ or marker.get("store_identity") != store_identity_payload
467
+ or marker.get("namespace_owner") != namespace_owner
468
+ ):
469
+ raise ScraperDomainError(
470
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
471
+ message="artifact store backend, location, or owner changed for this database",
472
+ )
473
+
474
+ async def create_or_get(
475
+ self,
476
+ *,
477
+ caller_identity: str,
478
+ command: ScrapeJobCreate,
479
+ request_hash: str,
480
+ ) -> tuple[ScrapeJobMongoObject, bool]:
481
+ """Atomically create or reuse one deterministic idempotency identity.
482
+
483
+ Steps:
484
+ 1. derive the caller-scoped deterministic job identity;
485
+ 2. insert immutable command/budget/deadline facts exactly once;
486
+ 3. reject any reuse whose request hash differs.
487
+
488
+ Collection/query/index:
489
+ - `scrape_jobs`, deterministic `_id` plus `caller_idempotency_unique_idx`.
490
+
491
+ Side effects if changes:
492
+ - duplicate queue dispatch and idempotency conflict behavior.
493
+ """
494
+
495
+ job_id = uuid5(_JOB_NAMESPACE, f"{caller_identity}:{command.idempotency_key}")
496
+ now = utc_now()
497
+ job = ScrapeJobMongoObject(
498
+ job_id=job_id,
499
+ caller_identity=caller_identity,
500
+ create=command,
501
+ request_hash=request_hash,
502
+ state=ScrapeJobState.PENDING,
503
+ revision=1,
504
+ attempt_limit=self._attempt_limit,
505
+ deadline_at=now + timedelta(seconds=self._job_deadline_seconds),
506
+ created_at=now,
507
+ updated_at=now,
508
+ next_attempt_at=now,
509
+ )
510
+ before = await self.jobs.find_one_and_update(
511
+ {"_id": job_id},
512
+ {"$setOnInsert": self._job_document(job)},
513
+ upsert=True,
514
+ return_document=ReturnDocument.BEFORE,
515
+ )
516
+ stored = job if before is None else self._job_model(before)
517
+ if stored.request_hash != request_hash:
518
+ raise ScraperDomainError(
519
+ code=ScraperErrorCode.REVISION_CONFLICT,
520
+ message="idempotency key was already used for a different request",
521
+ )
522
+ return stored, before is not None
523
+
524
+ async def get_view(self, *, job_id: UUID) -> ScrapeJobView:
525
+ """Read one job plus ordered immutable attempts by supporting indexes.
526
+
527
+ Steps:
528
+ 1. load the canonical job by primary identity;
529
+ 2. load attempts through the ordered unique index;
530
+ 3. validate and convert both layers into one public view.
531
+
532
+ Collection/query/index:
533
+ - `scrape_jobs` `_id`; `scrape_attempts` `job_attempt_number_unique_idx`.
534
+
535
+ Side effects if changes:
536
+ - job polling and admin diagnostics.
537
+ """
538
+
539
+ raw = await self.jobs.find_one({"_id": job_id})
540
+ if raw is None:
541
+ raise self._not_found("scrape job")
542
+ attempt_docs = (
543
+ await self.attempts.find({"job_id": job_id})
544
+ .sort("attempt.attempt_number", ASCENDING)
545
+ .to_list(length=100)
546
+ )
547
+ attempts = tuple(
548
+ ScrapeAttemptMongoObject.model_validate(self._without_id(item)).attempt
549
+ for item in attempt_docs
550
+ )
551
+ return self._job_model(raw).to_view(attempts=attempts)
552
+
553
+ async def list_views(
554
+ self,
555
+ *,
556
+ limit: int,
557
+ before: datetime | None,
558
+ ) -> tuple[ScrapeJobView, ...]:
559
+ """List newest jobs through the `created_at_desc_idx` cursor shape.
560
+
561
+ Steps:
562
+ 1. apply optional created-time cursor and bounded limit;
563
+ 2. load rows in descending creation order;
564
+ 3. hydrate each selected job into its complete view.
565
+
566
+ Side effects if changes:
567
+ - operations dashboard pagination and Mongo load.
568
+ """
569
+
570
+ query: dict[str, object] = {}
571
+ if before is not None:
572
+ query["created_at"] = {"$lt": before}
573
+ rows = (
574
+ await self.jobs.find(query)
575
+ .sort("created_at", DESCENDING)
576
+ .limit(limit)
577
+ .to_list(length=limit)
578
+ )
579
+ views = await asyncio.gather(
580
+ *(self.get_view(job_id=self._job_model(row).job_id) for row in rows)
581
+ )
582
+ return tuple(views)
583
+
584
+ async def cancel(self, *, job_id: UUID, command: ScrapeJobCancel) -> ScrapeJobView:
585
+ """Optimistically transition a non-terminal job to CANCELLED.
586
+
587
+ Steps:
588
+ 1. match job identity, expected revision, and cancellable state;
589
+ 2. write finite cancellation evidence and clear lease/retry fields;
590
+ 3. return the winner or raise a typed conflict/not-found result.
591
+
592
+ Collection/query/index:
593
+ - `scrape_jobs` `_id` and revision in one atomic update.
594
+
595
+ Side effects if changes:
596
+ - worker late acknowledgement and cancellation audit semantics.
597
+ """
598
+
599
+ now = utc_now()
600
+ cancellation = build_local_failure(
601
+ code=ScrapeLocalFailureCode.OPERATOR_CANCELLED,
602
+ safe_message="Scrape job was cancelled by an operator.",
603
+ observed_at=now,
604
+ physical_attempt_count=0,
605
+ )
606
+ raw = await self.jobs.find_one_and_update(
607
+ {
608
+ "_id": job_id,
609
+ "revision": command.expected_revision,
610
+ "state": {
611
+ "$in": [
612
+ ScrapeJobState.PENDING.value,
613
+ ScrapeJobState.LEASED.value,
614
+ ScrapeJobState.RETRY_SCHEDULED.value,
615
+ ]
616
+ },
617
+ },
618
+ {
619
+ "$set": {
620
+ "state": ScrapeJobState.CANCELLED.value,
621
+ "updated_at": now,
622
+ "history_expires_at": now + self._terminal_history_retention,
623
+ "terminal_failure": cancellation.model_dump(
624
+ mode="python",
625
+ by_alias=False,
626
+ ),
627
+ },
628
+ "$inc": {"revision": 1},
629
+ "$unset": {
630
+ "lease_owner": "",
631
+ "lease_expires_at": "",
632
+ "next_attempt_at": "",
633
+ },
634
+ },
635
+ return_document=ReturnDocument.AFTER,
636
+ )
637
+ if raw is None:
638
+ raise ScraperDomainError(
639
+ code=ScraperErrorCode.REVISION_CONFLICT,
640
+ message="job revision or lifecycle no longer permits cancellation",
641
+ )
642
+ return await self.get_view(job_id=job_id)
643
+
644
+ async def lease_due(
645
+ self,
646
+ *,
647
+ job_id: UUID,
648
+ worker_id: str,
649
+ lease_seconds: int,
650
+ ) -> FetchLease | None:
651
+ """Lease one due job only inside its immutable paid-work budget.
652
+
653
+ Steps:
654
+ 1. require a pending/retry row whose due time has arrived;
655
+ 2. reject rows at their stored attempt limit or deadline;
656
+ 3. atomically assign owner/expiry and increment the attempt ordinal.
657
+
658
+ Collection/query/index:
659
+ - `scrape_jobs`, `_id` plus `state_next_attempt_idx` lifecycle fields.
660
+
661
+ Side effects if changes:
662
+ - worker exclusivity and late acknowledgement prevention.
663
+ """
664
+
665
+ now = utc_now()
666
+ raw = await self.jobs.find_one_and_update(
667
+ {
668
+ "_id": job_id,
669
+ "state": {
670
+ "$in": [
671
+ ScrapeJobState.PENDING.value,
672
+ ScrapeJobState.RETRY_SCHEDULED.value,
673
+ ]
674
+ },
675
+ "next_attempt_at": {"$lte": now},
676
+ "deadline_at": {"$gt": now},
677
+ "$expr": {"$lt": ["$attempt_count", "$attempt_limit"]},
678
+ },
679
+ {
680
+ "$set": {
681
+ "state": ScrapeJobState.LEASED.value,
682
+ "lease_owner": worker_id,
683
+ "lease_expires_at": now + timedelta(seconds=lease_seconds),
684
+ "updated_at": now,
685
+ },
686
+ "$inc": {"revision": 1, "attempt_count": 1},
687
+ },
688
+ return_document=ReturnDocument.AFTER,
689
+ )
690
+ if raw is None:
691
+ return None
692
+ job = self._job_model(raw)
693
+ return FetchLease(
694
+ job=job, attempt_number=job.attempt_count, lease_owner=worker_id
695
+ )
696
+
697
+ async def append_attempt(self, *, job_id: UUID, attempt: ScrapeAttemptView) -> None:
698
+ """Atomically append attempt evidence and add measured job cost.
699
+
700
+ Steps:
701
+ 1. insert the immutable attempt under its unique ordinal;
702
+ 2. add exact estimated/actual/usage units to the job aggregate;
703
+ 3. commit both writes in one snapshot transaction.
704
+
705
+ Collection/query/index:
706
+ - `scrape_attempts` `job_attempt_number_unique_idx`; `scrape_jobs` `_id`.
707
+
708
+ Side effects if changes:
709
+ - attempts remain append-only and aggregate cost stays ACID-consistent.
710
+ """
711
+
712
+ row = ScrapeAttemptMongoObject(job_id=job_id, attempt=attempt)
713
+ async with self._client.start_session() as session:
714
+ async with await session.start_transaction(
715
+ read_concern=ReadConcern("snapshot"),
716
+ write_concern=WriteConcern("majority"),
717
+ ):
718
+ await self.attempts.insert_one(
719
+ {
720
+ "_id": attempt.attempt_id,
721
+ **row.model_dump(mode="python", by_alias=False),
722
+ },
723
+ session=session,
724
+ )
725
+ await self.jobs.update_one(
726
+ {"_id": job_id},
727
+ {
728
+ "$inc": {
729
+ "total_cost.estimated_amount": attempt.cost.estimated_amount,
730
+ "total_cost.actual_amount": attempt.cost.actual_amount,
731
+ "total_cost.bytes_transferred": attempt.cost.bytes_transferred,
732
+ "total_cost.browser_seconds": attempt.cost.browser_seconds,
733
+ }
734
+ },
735
+ session=session,
736
+ )
737
+
738
+ async def complete(
739
+ self,
740
+ *,
741
+ lease: FetchLease,
742
+ artifacts: tuple[ScrapeArtifactRef, ...],
743
+ event: ScrapeCompletionEvent | None,
744
+ ) -> ScrapeJobView:
745
+ """Commit terminal success and callback outbox in one transaction.
746
+
747
+ Steps:
748
+ 1. fence the exact leased revision and owner;
749
+ 2. publish terminal artifacts/cost state;
750
+ 3. insert the deterministic callback event in the same transaction.
751
+
752
+ Side effects if changes:
753
+ - job truth, artifact references, and Shopify callbacks must stay atomic.
754
+ """
755
+
756
+ now = utc_now()
757
+ terminal_values: dict[str, object] = {
758
+ "state": ScrapeJobState.SUCCEEDED.value,
759
+ "artifact_ids": [item.artifact_id for item in artifacts],
760
+ "updated_at": now,
761
+ }
762
+ if event is None:
763
+ terminal_values["history_expires_at"] = (
764
+ now + self._terminal_history_retention
765
+ )
766
+ async with self._client.start_session() as session:
767
+ async with await session.start_transaction(
768
+ read_concern=ReadConcern("snapshot"),
769
+ write_concern=WriteConcern("majority"),
770
+ ):
771
+ updated = await self.jobs.update_one(
772
+ {
773
+ "_id": lease.job.job_id,
774
+ "state": ScrapeJobState.LEASED.value,
775
+ "lease_owner": lease.lease_owner,
776
+ "revision": lease.job.revision,
777
+ },
778
+ {
779
+ "$set": terminal_values,
780
+ "$inc": {"revision": 1},
781
+ "$unset": {
782
+ "lease_owner": "",
783
+ "lease_expires_at": "",
784
+ "next_attempt_at": "",
785
+ "terminal_failure": "",
786
+ },
787
+ },
788
+ session=session,
789
+ )
790
+ if updated.modified_count != 1:
791
+ raise self._late_acknowledgement()
792
+ if event is not None:
793
+ outbox = ScrapeOutboxMongoObject(
794
+ event=event,
795
+ attempt_limit=lease.job.attempt_limit,
796
+ next_attempt_at=now,
797
+ )
798
+ await self.outbox.insert_one(
799
+ {
800
+ "_id": event.event_id,
801
+ **outbox.model_dump(mode="python", by_alias=False),
802
+ },
803
+ session=session,
804
+ )
805
+ return await self.get_view(job_id=lease.job.job_id)
806
+
807
+ async def schedule_retry(
808
+ self,
809
+ *,
810
+ lease: FetchLease,
811
+ failure: ScrapeFailure,
812
+ next_attempt_at: datetime,
813
+ dead_letter: bool,
814
+ event: ScrapeCompletionEvent | None,
815
+ ) -> ScrapeJobView:
816
+ """Commit retry/dead-letter state and typed outbox consistently.
817
+
818
+ Steps:
819
+ 1. fence the exact leased revision and owner;
820
+ 2. clear the lease into due retry or terminal dead-letter state;
821
+ 3. insert a terminal callback only when the job is final.
822
+
823
+ Side effects if changes:
824
+ - retry timing, stale leases, and failure callbacks.
825
+ """
826
+
827
+ state = (
828
+ ScrapeJobState.DEAD_LETTER
829
+ if dead_letter
830
+ else ScrapeJobState.RETRY_SCHEDULED
831
+ )
832
+ now = utc_now()
833
+ terminal_failure = (
834
+ failure.model_dump(mode="python", by_alias=False) if dead_letter else None
835
+ )
836
+ lifecycle_values: dict[str, object] = {
837
+ "state": state.value,
838
+ "next_attempt_at": None if dead_letter else next_attempt_at,
839
+ "updated_at": now,
840
+ "terminal_failure": terminal_failure,
841
+ }
842
+ if dead_letter and event is None:
843
+ lifecycle_values["history_expires_at"] = (
844
+ now + self._terminal_history_retention
845
+ )
846
+ async with self._client.start_session() as session:
847
+ async with await session.start_transaction(
848
+ read_concern=ReadConcern("snapshot"),
849
+ write_concern=WriteConcern("majority"),
850
+ ):
851
+ updated = await self.jobs.update_one(
852
+ {
853
+ "_id": lease.job.job_id,
854
+ "state": ScrapeJobState.LEASED.value,
855
+ "lease_owner": lease.lease_owner,
856
+ "revision": lease.job.revision,
857
+ },
858
+ {
859
+ "$set": lifecycle_values,
860
+ "$inc": {"revision": 1},
861
+ "$unset": {"lease_owner": "", "lease_expires_at": ""},
862
+ },
863
+ session=session,
864
+ )
865
+ if updated.modified_count != 1:
866
+ raise self._late_acknowledgement()
867
+ if event is not None:
868
+ outbox = ScrapeOutboxMongoObject(
869
+ event=event,
870
+ attempt_limit=lease.job.attempt_limit,
871
+ next_attempt_at=now,
872
+ )
873
+ await self.outbox.insert_one(
874
+ {
875
+ "_id": event.event_id,
876
+ **outbox.model_dump(mode="python", by_alias=False),
877
+ },
878
+ session=session,
879
+ )
880
+ return await self.get_view(job_id=lease.job.job_id)
881
+
882
+ async def recover_expired_leases(
883
+ self,
884
+ *,
885
+ now: datetime,
886
+ limit: int = 1_000,
887
+ ) -> LeaseRecoverySummary:
888
+ """Recover expired worker leases without bypassing stored work budgets.
889
+
890
+ Steps:
891
+ 1. read a bounded expiry-ordered batch through ``state_lease_expiry_idx``;
892
+ 2. return still-eligible rows to durable retry state with exact revision fencing;
893
+ 3. terminalize attempt/deadline-exhausted rows and their callback outbox atomically.
894
+
895
+ Side effects if changes:
896
+ - crashed workers may retry a paid route only inside the immutable job budget;
897
+ - exhausted jobs and Shopify callbacks become durable terminal facts.
898
+ """
899
+
900
+ rows = (
901
+ await self.jobs.find(
902
+ {
903
+ "state": ScrapeJobState.LEASED.value,
904
+ "lease_expires_at": {"$lte": now},
905
+ }
906
+ )
907
+ .sort("lease_expires_at", ASCENDING)
908
+ .limit(limit)
909
+ .to_list(length=limit)
910
+ )
911
+ recovered_count = 0
912
+ terminalized_count = 0
913
+ for raw in rows:
914
+ job = self._job_model(raw)
915
+ if self._job_budget_exhausted(job=job, now=now):
916
+ terminalized_count += int(
917
+ await self._terminalize_exhausted_job(job=job, now=now)
918
+ )
919
+ continue
920
+ recovered = await self.jobs.update_one(
921
+ {
922
+ "_id": job.job_id,
923
+ "state": ScrapeJobState.LEASED.value,
924
+ "revision": job.revision,
925
+ "lease_owner": job.lease_owner,
926
+ "lease_expires_at": {"$lte": now},
927
+ },
928
+ {
929
+ "$set": {
930
+ "state": ScrapeJobState.RETRY_SCHEDULED.value,
931
+ "next_attempt_at": now,
932
+ "updated_at": now,
933
+ },
934
+ "$inc": {"revision": 1},
935
+ "$unset": {"lease_owner": "", "lease_expires_at": ""},
936
+ },
937
+ )
938
+ recovered_count += recovered.modified_count
939
+ return LeaseRecoverySummary(
940
+ recovered_count=recovered_count,
941
+ terminalized_count=terminalized_count,
942
+ )
943
+
944
+ async def terminalize_exhausted_due_jobs(
945
+ self,
946
+ *,
947
+ now: datetime,
948
+ limit: int = 1_000,
949
+ ) -> int:
950
+ """Terminalize non-leased jobs whose immutable budget is already spent.
951
+
952
+ Steps:
953
+ 1. select due pending/retry rows at their attempt limit or deadline;
954
+ 2. re-check the exact revision inside each majority transaction;
955
+ 3. commit dead-letter state and deterministic callback outbox together.
956
+
957
+ Side effects if changes:
958
+ - queued stale messages cannot restart paid work after policy exhaustion;
959
+ - due-job dispatch excludes every terminalized identity.
960
+ """
961
+
962
+ rows = (
963
+ await self.jobs.find(
964
+ {
965
+ "state": {
966
+ "$in": [
967
+ ScrapeJobState.PENDING.value,
968
+ ScrapeJobState.RETRY_SCHEDULED.value,
969
+ ]
970
+ },
971
+ "next_attempt_at": {"$lte": now},
972
+ "$or": [
973
+ {"deadline_at": {"$lte": now}},
974
+ {"$expr": {"$gte": ["$attempt_count", "$attempt_limit"]}},
975
+ ],
976
+ }
977
+ )
978
+ .sort("next_attempt_at", ASCENDING)
979
+ .limit(limit)
980
+ .to_list(length=limit)
981
+ )
982
+ terminalized_count = 0
983
+ for raw in rows:
984
+ terminalized_count += int(
985
+ await self._terminalize_exhausted_job(
986
+ job=self._job_model(raw),
987
+ now=now,
988
+ )
989
+ )
990
+ return terminalized_count
991
+
992
+ async def due_jobs(
993
+ self,
994
+ *,
995
+ now: datetime,
996
+ limit: int,
997
+ ) -> tuple[DispatchableJob, ...]:
998
+ """Project all queue fields for one bounded eligible due-job batch.
999
+
1000
+ Steps:
1001
+ 1. use ``state_next_attempt_idx`` for due lifecycle ordering;
1002
+ 2. exclude attempt/deadline-exhausted work before broker publication;
1003
+ 3. project only identity and transport policy in one Mongo query.
1004
+
1005
+ Side effects if changes:
1006
+ - maintenance avoids per-job reads and publishes only eligible work.
1007
+ """
1008
+
1009
+ rows = (
1010
+ await self.jobs.find(
1011
+ {
1012
+ "state": {
1013
+ "$in": [
1014
+ ScrapeJobState.PENDING.value,
1015
+ ScrapeJobState.RETRY_SCHEDULED.value,
1016
+ ]
1017
+ },
1018
+ "next_attempt_at": {"$lte": now},
1019
+ "deadline_at": {"$gt": now},
1020
+ "$expr": {"$lt": ["$attempt_count", "$attempt_limit"]},
1021
+ },
1022
+ {"_id": 1, "create.transport_policy": 1},
1023
+ )
1024
+ .sort("next_attempt_at", ASCENDING)
1025
+ .limit(limit)
1026
+ .to_list(length=limit)
1027
+ )
1028
+ return tuple(
1029
+ DispatchableJob(
1030
+ job_id=row["_id"],
1031
+ transport_policy=ScrapeTransportPolicy(
1032
+ row["create"]["transport_policy"]
1033
+ ),
1034
+ )
1035
+ for row in rows
1036
+ )
1037
+
1038
+ async def _terminalize_exhausted_job(
1039
+ self,
1040
+ *,
1041
+ job: ScrapeJobMongoObject,
1042
+ now: datetime,
1043
+ ) -> bool:
1044
+ """Commit one exact exhausted job and callback in a majority transaction.
1045
+
1046
+ Steps:
1047
+ 1. distinguish deadline expiry from attempt-budget exhaustion;
1048
+ 2. build one typed terminal failure and deterministic callback event;
1049
+ 3. fence the current revision/state before writing both records atomically.
1050
+
1051
+ Side effects if changes:
1052
+ - terminal job truth, outbox delivery, and late-worker rejection stay consistent.
1053
+ """
1054
+
1055
+ deadline_exceeded = job.deadline_at <= now
1056
+ code = (
1057
+ ScrapeLocalFailureCode.JOB_DEADLINE_EXCEEDED
1058
+ if deadline_exceeded
1059
+ else ScrapeLocalFailureCode.ATTEMPT_BUDGET_EXHAUSTED
1060
+ )
1061
+ failure = build_local_failure(
1062
+ code=code,
1063
+ safe_message=(
1064
+ "Scraper job deadline expired before safe completion."
1065
+ if deadline_exceeded
1066
+ else "Automatic scraper attempt budget is exhausted."
1067
+ ),
1068
+ observed_at=now,
1069
+ physical_attempt_count=job.attempt_count,
1070
+ )
1071
+ event = build_completion_event(
1072
+ job=job,
1073
+ state=ScrapeJobState.DEAD_LETTER,
1074
+ artifacts=(),
1075
+ failure=failure,
1076
+ occurred_at=now,
1077
+ )
1078
+ terminal_values: dict[str, object] = {
1079
+ "state": ScrapeJobState.DEAD_LETTER.value,
1080
+ "terminal_failure": failure.model_dump(
1081
+ mode="python",
1082
+ by_alias=False,
1083
+ ),
1084
+ "next_attempt_at": None,
1085
+ "updated_at": now,
1086
+ }
1087
+ if event is None:
1088
+ terminal_values["history_expires_at"] = (
1089
+ now + self._terminal_history_retention
1090
+ )
1091
+ async with self._client.start_session() as session:
1092
+ async with await session.start_transaction(
1093
+ read_concern=ReadConcern("snapshot"),
1094
+ write_concern=WriteConcern("majority"),
1095
+ ):
1096
+ updated = await self.jobs.update_one(
1097
+ {
1098
+ "_id": job.job_id,
1099
+ "revision": job.revision,
1100
+ "state": job.state.value,
1101
+ },
1102
+ {
1103
+ "$set": terminal_values,
1104
+ "$inc": {"revision": 1},
1105
+ "$unset": {"lease_owner": "", "lease_expires_at": ""},
1106
+ },
1107
+ session=session,
1108
+ )
1109
+ if updated.modified_count != 1:
1110
+ return False
1111
+ if event is not None:
1112
+ outbox = ScrapeOutboxMongoObject(
1113
+ event=event,
1114
+ attempt_limit=job.attempt_limit,
1115
+ next_attempt_at=now,
1116
+ )
1117
+ await self.outbox.insert_one(
1118
+ {
1119
+ "_id": event.event_id,
1120
+ **outbox.model_dump(mode="python", by_alias=False),
1121
+ },
1122
+ session=session,
1123
+ )
1124
+ return True
1125
+
1126
+ @staticmethod
1127
+ def _job_budget_exhausted(
1128
+ *,
1129
+ job: ScrapeJobMongoObject,
1130
+ now: datetime,
1131
+ ) -> bool:
1132
+ """Return whether a stored attempt limit or deadline forbids more work.
1133
+
1134
+ Steps:
1135
+ 1. compare the durable attempt ordinal with its immutable limit;
1136
+ 2. compare the current time with the immutable job deadline;
1137
+ 3. return one boolean used by both lease and due-state recovery.
1138
+
1139
+ Side effects if changes:
1140
+ - paid retry admission and terminal recovery share this exact predicate.
1141
+ """
1142
+
1143
+ return job.attempt_count >= job.attempt_limit or job.deadline_at <= now
1144
+
1145
+ async def get_valid(
1146
+ self,
1147
+ *,
1148
+ request_hash: str,
1149
+ mode: ScrapeCacheMode,
1150
+ now: datetime,
1151
+ ) -> ScrapeCacheEntryMongoObject | None:
1152
+ """Read one cache pointer through `request_hash_unique_idx`.
1153
+
1154
+ Side effects if changes:
1155
+ - reuse, conditional revalidation, and bypass semantics.
1156
+ """
1157
+
1158
+ if mode is ScrapeCacheMode.BYPASS:
1159
+ return None
1160
+ query: dict[str, object] = {"request_hash": request_hash}
1161
+ if mode is ScrapeCacheMode.REUSE_VALID:
1162
+ query["expires_at"] = {"$gt": now}
1163
+ raw = await self.cache.find_one(query)
1164
+ return (
1165
+ ScrapeCacheEntryMongoObject.model_validate(self._without_id(raw))
1166
+ if raw is not None
1167
+ else None
1168
+ )
1169
+
1170
+ async def upsert(self, *, entry: ScrapeCacheEntryMongoObject) -> None:
1171
+ """Upsert one complete cache pointer by request identity.
1172
+
1173
+ Steps:
1174
+ 1. key the pointer by canonical request hash;
1175
+ 2. replace all validators/artifact identities atomically;
1176
+ 3. retain its bounded expiry and compare-delete timestamp.
1177
+
1178
+ Side effects if changes:
1179
+ - future cache hits and conditional validators.
1180
+ """
1181
+
1182
+ await self.cache.replace_one(
1183
+ {"request_hash": entry.request_hash},
1184
+ {
1185
+ "_id": entry.request_hash,
1186
+ **entry.model_dump(mode="python", by_alias=False),
1187
+ },
1188
+ upsert=True,
1189
+ )
1190
+
1191
+ async def delete_current(
1192
+ self,
1193
+ *,
1194
+ request_hash: str,
1195
+ updated_at: datetime,
1196
+ ) -> bool:
1197
+ """Delete only the exact stale cache revision observed by one worker.
1198
+
1199
+ Steps:
1200
+ 1. match the request identity and observed cache timestamp;
1201
+ 2. delete at most that exact pointer revision;
1202
+ 3. preserve any concurrently refreshed pointer.
1203
+
1204
+ Side effects if changes:
1205
+ - missing artifact recovery cannot erase a newer cache write.
1206
+ """
1207
+
1208
+ deleted = await self.cache.delete_one(
1209
+ {
1210
+ "request_hash": request_hash,
1211
+ "updated_at": updated_at,
1212
+ }
1213
+ )
1214
+ return deleted.deleted_count == 1
1215
+
1216
+ async def delete_expired(self, *, now: datetime) -> int:
1217
+ """Delete expired cache pointers through `expires_at_idx`.
1218
+
1219
+ Side effects if changes:
1220
+ - cache retention and later artifact orphan eligibility.
1221
+ """
1222
+
1223
+ result = await self.cache.delete_many({"expires_at": {"$lte": now}})
1224
+ return result.deleted_count
1225
+
1226
+ async def register(
1227
+ self, *, artifact: ScrapeArtifactRef, object_key: str
1228
+ ) -> ScrapeArtifactRef:
1229
+ """Atomically register or strengthen content metadata outside deletion leases.
1230
+
1231
+ Steps:
1232
+ 1. insert the stable content-addressed row if absent;
1233
+ 2. strengthen trust/retention/expiry without downgrade;
1234
+ 3. reject active deletion leases and validate the stored winner.
1235
+
1236
+ Merge policy:
1237
+ 1. trust can promote from untrusted to verified but never downgrade;
1238
+ 2. retention can strengthen from transient to cache to archive;
1239
+ 3. expiry extends and creation time preserves the earliest observation;
1240
+ 4. active cleanup leases reject recapture before object bytes are written.
1241
+
1242
+ Side effects if changes:
1243
+ - content deduplication, cleanup races, retention, and download resolution.
1244
+ """
1245
+
1246
+ row = ScrapeArtifactMongoObject(artifact=artifact, object_key=object_key)
1247
+ await self.artifacts.update_one(
1248
+ {"_id": artifact.artifact_id},
1249
+ {"$setOnInsert": row.model_dump(mode="python", by_alias=False)},
1250
+ upsert=True,
1251
+ )
1252
+ now = utc_now()
1253
+ set_values: dict[str, object] = {
1254
+ "deletion_lease_id": None,
1255
+ "deletion_lease_expires_at": None,
1256
+ }
1257
+ if artifact.trust is ScrapeArtifactTrust.VERIFIED:
1258
+ set_values["artifact.trust"] = ScrapeArtifactTrust.VERIFIED
1259
+ merged = await self.artifacts.update_one(
1260
+ {
1261
+ "_id": artifact.artifact_id,
1262
+ "$or": [
1263
+ {"deletion_lease_id": None},
1264
+ {"deletion_lease_id": {"$exists": False}},
1265
+ {"deletion_lease_expires_at": {"$lte": now}},
1266
+ ],
1267
+ },
1268
+ {
1269
+ "$set": set_values,
1270
+ "$max": {"artifact.expires_at": artifact.expires_at},
1271
+ "$min": {"artifact.created_at": artifact.created_at},
1272
+ },
1273
+ )
1274
+ if merged.matched_count != 1:
1275
+ raise ScraperDomainError(
1276
+ code=ScraperErrorCode.REVISION_CONFLICT,
1277
+ message="artifact is currently leased for deletion",
1278
+ )
1279
+ if artifact.retention_class is ScrapeRetentionClass.REFERENCED_ARCHIVE:
1280
+ await self.artifacts.update_one(
1281
+ {"_id": artifact.artifact_id},
1282
+ {"$set": {"artifact.retention_class": artifact.retention_class}},
1283
+ )
1284
+ elif artifact.retention_class is ScrapeRetentionClass.CACHE_7D:
1285
+ await self.artifacts.update_one(
1286
+ {
1287
+ "_id": artifact.artifact_id,
1288
+ "artifact.retention_class": ScrapeRetentionClass.TRANSIENT_24H,
1289
+ },
1290
+ {"$set": {"artifact.retention_class": artifact.retention_class}},
1291
+ )
1292
+ raw = await self.artifacts.find_one({"_id": artifact.artifact_id})
1293
+ if raw is None:
1294
+ raise self._not_found("scrape artifact")
1295
+ stored = ScrapeArtifactMongoObject.model_validate(self._without_id(raw))
1296
+ if stored.object_key != object_key:
1297
+ raise ScraperDomainError(
1298
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
1299
+ message="artifact identity resolved to a different object key",
1300
+ )
1301
+ return stored.artifact
1302
+
1303
+ async def get(self, *, artifact_id: UUID) -> ScrapeArtifactRef:
1304
+ """Read one artifact by primary key.
1305
+
1306
+ Side effects if changes:
1307
+ - download authorization and downstream pins.
1308
+ """
1309
+
1310
+ raw = await self.artifacts.find_one({"_id": artifact_id})
1311
+ if raw is None:
1312
+ raise self._not_found("scrape artifact")
1313
+ return ScrapeArtifactMongoObject.model_validate(self._without_id(raw)).artifact
1314
+
1315
+ async def get_many_existing(
1316
+ self,
1317
+ *,
1318
+ artifact_ids: Sequence[UUID],
1319
+ ) -> ArtifactBatchLookup:
1320
+ """Resolve one ordered artifact batch without raising for stale identities.
1321
+
1322
+ Steps:
1323
+ 1. fetch all distinct requested IDs in one indexed ``_id`` query;
1324
+ 2. validate and map each persisted artifact by identity;
1325
+ 3. rebuild requested order and report every missing ID explicitly.
1326
+
1327
+ Side effects if changes:
1328
+ - cache completion performs one Mongo read and invalidates stale pointers safely.
1329
+ """
1330
+
1331
+ distinct_ids = tuple(dict.fromkeys(artifact_ids))
1332
+ rows = await self.artifacts.find({"_id": {"$in": distinct_ids}}).to_list(
1333
+ length=len(distinct_ids)
1334
+ )
1335
+ by_id = {
1336
+ row["_id"]: ScrapeArtifactMongoObject.model_validate(
1337
+ self._without_id(row)
1338
+ ).artifact
1339
+ for row in rows
1340
+ }
1341
+ missing = tuple(
1342
+ artifact_id for artifact_id in artifact_ids if artifact_id not in by_id
1343
+ )
1344
+ artifacts = tuple(
1345
+ by_id[artifact_id] for artifact_id in artifact_ids if artifact_id in by_id
1346
+ )
1347
+ return ArtifactBatchLookup(
1348
+ artifacts=artifacts,
1349
+ missing_artifact_ids=missing,
1350
+ )
1351
+
1352
+ async def object_key(self, *, artifact_id: UUID) -> str:
1353
+ """Resolve the private object key without exposing it in API models.
1354
+
1355
+ Side effects if changes:
1356
+ - signed downloads and orphan deletion.
1357
+ """
1358
+
1359
+ raw = await self.artifacts.find_one({"_id": artifact_id}, {"object_key": 1})
1360
+ if raw is None:
1361
+ raise self._not_found("scrape artifact")
1362
+ return str(raw["object_key"])
1363
+
1364
+ async def pin(self, *, pin: ScrapeArtifactPin) -> None:
1365
+ """Synchronize one monotonic retention intent and reference exactly once.
1366
+
1367
+ Steps:
1368
+ 1. Validate the artifact exists and is not under a live deletion lease.
1369
+ 2. Insert the stable owner pin and increment its reference only once.
1370
+ 3. Ignore stale revisions, accept exact replays, and reject split-brain reuse.
1371
+ 4. Compare-and-set a newer expiry and revision in the same transaction.
1372
+
1373
+ Side effects if changes:
1374
+ - downstream revision retention and orphan eligibility;
1375
+ - Shopify out-of-order outbox convergence and storage cost.
1376
+ """
1377
+
1378
+ pin_id = uuid5(_PIN_NAMESPACE, f"{pin.artifact_id}:{pin.owner_identity}")
1379
+ now = utc_now()
1380
+ async with self._client.start_session() as session:
1381
+ async with await session.start_transaction(
1382
+ read_concern=ReadConcern("snapshot"),
1383
+ write_concern=WriteConcern("majority"),
1384
+ ):
1385
+ existing_artifact = await self.artifacts.find_one(
1386
+ {"_id": pin.artifact_id},
1387
+ session=session,
1388
+ )
1389
+ if existing_artifact is None:
1390
+ raise self._not_found("scrape artifact")
1391
+ artifact_model = self._artifact_model(existing_artifact)
1392
+ if (
1393
+ artifact_model.deletion_lease_expires_at is not None
1394
+ and artifact_model.deletion_lease_expires_at > now
1395
+ ):
1396
+ raise ScraperDomainError(
1397
+ code=ScraperErrorCode.REVISION_CONFLICT,
1398
+ message="scrape artifact cleanup is already in progress",
1399
+ )
1400
+ row = ScrapeArtifactPinMongoObject(
1401
+ artifact_id=pin.artifact_id,
1402
+ owner_identity=pin.owner_identity,
1403
+ retention_class=artifact_model.artifact.retention_class,
1404
+ retention_revision=pin.retention_revision,
1405
+ expires_at=pin.expires_at,
1406
+ created_at=now,
1407
+ updated_at=now,
1408
+ )
1409
+ before = await self.artifact_pins.find_one_and_update(
1410
+ {"_id": pin_id},
1411
+ {"$setOnInsert": row.model_dump(mode="python", by_alias=False)},
1412
+ upsert=True,
1413
+ return_document=ReturnDocument.BEFORE,
1414
+ session=session,
1415
+ )
1416
+ if before is None:
1417
+ await self.artifacts.update_one(
1418
+ {"_id": pin.artifact_id},
1419
+ {
1420
+ "$inc": {"reference_count": 1},
1421
+ "$unset": {
1422
+ "deletion_lease_id": "",
1423
+ "deletion_lease_expires_at": "",
1424
+ },
1425
+ },
1426
+ session=session,
1427
+ )
1428
+ else:
1429
+ persisted = ScrapeArtifactPinMongoObject.model_validate(
1430
+ self._without_id(before)
1431
+ )
1432
+ if pin.retention_revision < persisted.retention_revision:
1433
+ return
1434
+ if pin.retention_revision == persisted.retention_revision:
1435
+ if not self._same_pin_expiry(
1436
+ left=pin.expires_at,
1437
+ right=persisted.expires_at,
1438
+ ):
1439
+ raise ScraperDomainError(
1440
+ code=ScraperErrorCode.REVISION_CONFLICT,
1441
+ message=(
1442
+ "artifact retention revision was reused with "
1443
+ "different expiry policy"
1444
+ ),
1445
+ )
1446
+ return
1447
+ advanced = await self.artifact_pins.update_one(
1448
+ {
1449
+ "_id": pin_id,
1450
+ "retention_revision": persisted.retention_revision,
1451
+ },
1452
+ {
1453
+ "$set": {
1454
+ "retention_revision": pin.retention_revision,
1455
+ "expires_at": pin.expires_at,
1456
+ "updated_at": now,
1457
+ }
1458
+ },
1459
+ session=session,
1460
+ )
1461
+ if advanced.modified_count != 1:
1462
+ raise ScraperDomainError(
1463
+ code=ScraperErrorCode.REVISION_CONFLICT,
1464
+ message="artifact retention changed during compare-and-set",
1465
+ )
1466
+
1467
+ @staticmethod
1468
+ def _same_pin_expiry(
1469
+ *,
1470
+ left: datetime | None,
1471
+ right: datetime | None,
1472
+ ) -> bool:
1473
+ """Compare expiry values at MongoDB's millisecond datetime precision.
1474
+
1475
+ Steps:
1476
+ 1. Preserve `None` as the explicit indefinite-retention policy.
1477
+ 2. Truncate aware finite timestamps to persisted millisecond precision.
1478
+ 3. Return equality only for one replay-equivalent policy.
1479
+
1480
+ Side effects if changes:
1481
+ - same-revision pin replay acceptance;
1482
+ - false split-brain conflict detection after Mongo round trips.
1483
+ """
1484
+
1485
+ if left is None or right is None:
1486
+ return left is right
1487
+ left_millisecond = left.replace(microsecond=(left.microsecond // 1_000) * 1_000)
1488
+ right_millisecond = right.replace(
1489
+ microsecond=(right.microsecond // 1_000) * 1_000
1490
+ )
1491
+ return left_millisecond == right_millisecond
1492
+
1493
+ async def release_expired_pins(self, *, now: datetime) -> int:
1494
+ """Delete expired pins and decrement artifact references transactionally.
1495
+
1496
+ Steps:
1497
+ 1. select a bounded indexed set of expired owner pins;
1498
+ 2. delete pins and decrement each artifact reference count atomically;
1499
+ 3. return the exact released pin count.
1500
+
1501
+ Side effects if changes:
1502
+ - twelve-month retention and orphan eligibility.
1503
+ """
1504
+
1505
+ rows = (
1506
+ await self.artifact_pins.find({"expires_at": {"$lte": now}})
1507
+ .limit(1_000)
1508
+ .to_list(length=1_000)
1509
+ )
1510
+ if not rows:
1511
+ return 0
1512
+ async with self._client.start_session() as session:
1513
+ async with await session.start_transaction(
1514
+ read_concern=ReadConcern("snapshot"),
1515
+ write_concern=WriteConcern("majority"),
1516
+ ):
1517
+ await self.artifact_pins.delete_many(
1518
+ {"_id": {"$in": [row["_id"] for row in rows]}},
1519
+ session=session,
1520
+ )
1521
+ for row in rows:
1522
+ await self.artifacts.update_one(
1523
+ {"_id": row["artifact_id"], "reference_count": {"$gt": 0}},
1524
+ {"$inc": {"reference_count": -1}},
1525
+ session=session,
1526
+ )
1527
+ return len(rows)
1528
+
1529
+ async def lease_orphan_object_keys(
1530
+ self,
1531
+ *,
1532
+ now: datetime,
1533
+ limit: int,
1534
+ lease_id: UUID,
1535
+ lease_seconds: int,
1536
+ ) -> tuple[str, ...]:
1537
+ """Atomically lease expired unreferenced objects through `orphan_expiry_idx`.
1538
+
1539
+ Side effects if changes:
1540
+ - maintenance object deletion exclusivity and storage cost.
1541
+ """
1542
+
1543
+ object_keys: list[str] = []
1544
+ for _ in range(limit):
1545
+ row = await self.artifacts.find_one_and_update(
1546
+ {
1547
+ "reference_count": 0,
1548
+ "artifact.expires_at": {"$lte": now},
1549
+ "$or": [
1550
+ {"deletion_lease_expires_at": {"$exists": False}},
1551
+ {"deletion_lease_expires_at": None},
1552
+ {"deletion_lease_expires_at": {"$lte": now}},
1553
+ ],
1554
+ },
1555
+ {
1556
+ "$set": {
1557
+ "deletion_lease_id": lease_id,
1558
+ "deletion_lease_expires_at": now
1559
+ + timedelta(seconds=lease_seconds),
1560
+ }
1561
+ },
1562
+ sort=[("artifact.expires_at", ASCENDING)],
1563
+ return_document=ReturnDocument.AFTER,
1564
+ )
1565
+ if row is None:
1566
+ break
1567
+ object_keys.append(str(row["object_key"]))
1568
+ return tuple(object_keys)
1569
+
1570
+ async def complete_orphan_deletion(
1571
+ self,
1572
+ *,
1573
+ object_keys: Sequence[str],
1574
+ lease_id: UUID,
1575
+ ) -> int:
1576
+ """Delete metadata only after the same lease deleted object-store bytes.
1577
+
1578
+ Side effects if changes:
1579
+ - orphan cleanup ordering and private object accounting.
1580
+ """
1581
+
1582
+ if not object_keys:
1583
+ return 0
1584
+ result = await self.artifacts.delete_many(
1585
+ {
1586
+ "object_key": {"$in": list(object_keys)},
1587
+ "reference_count": 0,
1588
+ "deletion_lease_id": lease_id,
1589
+ }
1590
+ )
1591
+ return result.deleted_count
1592
+
1593
+ async def release_orphan_deletion(
1594
+ self,
1595
+ *,
1596
+ object_keys: Sequence[str],
1597
+ lease_id: UUID,
1598
+ ) -> None:
1599
+ """Release object leases after an idempotent store deletion failure.
1600
+
1601
+ Side effects if changes:
1602
+ - orphan retry eligibility and pin availability.
1603
+ """
1604
+
1605
+ if not object_keys:
1606
+ return
1607
+ await self.artifacts.update_many(
1608
+ {
1609
+ "object_key": {"$in": list(object_keys)},
1610
+ "deletion_lease_id": lease_id,
1611
+ },
1612
+ {
1613
+ "$unset": {
1614
+ "deletion_lease_id": "",
1615
+ "deletion_lease_expires_at": "",
1616
+ }
1617
+ },
1618
+ )
1619
+
1620
+ async def import_candidate(
1621
+ self, *, command: ProxyCandidateCreate
1622
+ ) -> ProxyCandidateView:
1623
+ """Encrypt and insert one candidate in mandatory quarantine.
1624
+
1625
+ Steps:
1626
+ 1. normalize/redact and encrypt the submitted proxy credential;
1627
+ 2. insert a deterministic quarantined candidate exactly once;
1628
+ 3. return only the credential-free operational view.
1629
+
1630
+ Side effects if changes:
1631
+ - future experimental egress and proxy audit visibility.
1632
+ """
1633
+
1634
+ redacted, encrypted = self._cipher.encode(command=command)
1635
+ now = utc_now()
1636
+ candidate_id = uuid5(_PROXY_NAMESPACE, redacted)
1637
+ candidate = ProxyCandidateMongoObject(
1638
+ candidate_id=candidate_id,
1639
+ endpoint_redacted=redacted,
1640
+ encrypted_proxy_url=encrypted,
1641
+ source=command.source,
1642
+ source_reference=command.source_reference,
1643
+ state=ProxyCandidateState.QUARANTINED,
1644
+ health_score=Decimal("0"),
1645
+ next_health_at=now,
1646
+ created_at=now,
1647
+ updated_at=now,
1648
+ )
1649
+ raw = await self.proxies.find_one_and_update(
1650
+ {"_id": candidate_id},
1651
+ {"$setOnInsert": candidate.model_dump(mode="python", by_alias=False)},
1652
+ upsert=True,
1653
+ return_document=ReturnDocument.AFTER,
1654
+ )
1655
+ if raw is None:
1656
+ raise self._not_found("proxy candidate")
1657
+ return self._proxy_view(self._proxy_model(raw))
1658
+
1659
+ async def list_candidates(self, *, limit: int) -> tuple[ProxyCandidateView, ...]:
1660
+ """List best candidates through `health_created_desc_idx`.
1661
+
1662
+ Steps:
1663
+ 1. bound the query by operator limit;
1664
+ 2. order by health then recency through the supporting index;
1665
+ 3. convert every encrypted row to a redacted view.
1666
+
1667
+ Side effects if changes:
1668
+ - proxy dashboard ordering.
1669
+ """
1670
+
1671
+ rows = (
1672
+ await self.proxies.find({})
1673
+ .sort([("health_score", DESCENDING), ("created_at", DESCENDING)])
1674
+ .limit(limit)
1675
+ .to_list(length=limit)
1676
+ )
1677
+ return tuple(self._proxy_view(self._proxy_model(row)) for row in rows)
1678
+
1679
+ async def choose_healthy(self) -> SecretStr | None:
1680
+ """Decrypt the best promoted experimental candidate at dispatch time.
1681
+
1682
+ Side effects if changes:
1683
+ - public experimental route egress.
1684
+ """
1685
+
1686
+ raw = await self.proxies.find_one(
1687
+ {"state": ProxyCandidateState.HEALTHY_EXPERIMENTAL.value},
1688
+ sort=[("health_score", DESCENDING)],
1689
+ )
1690
+ return (
1691
+ self._cipher.decode(encrypted_proxy_url=str(raw["encrypted_proxy_url"]))
1692
+ if raw is not None
1693
+ else None
1694
+ )
1695
+
1696
+ async def credential_for(self, *, candidate_id: UUID) -> SecretStr:
1697
+ """Decrypt one candidate credential solely for a health/dispatch worker.
1698
+
1699
+ Steps:
1700
+ 1. project only the encrypted credential by candidate identity;
1701
+ 2. reject a missing candidate with a typed not-found error;
1702
+ 3. decrypt into write-only in-memory request material.
1703
+
1704
+ Side effects if changes:
1705
+ - public proxy credentials enter one worker's in-memory request config.
1706
+ """
1707
+
1708
+ raw = await self.proxies.find_one(
1709
+ {"_id": candidate_id}, {"encrypted_proxy_url": 1}
1710
+ )
1711
+ if raw is None:
1712
+ raise self._not_found("proxy candidate")
1713
+ return self._cipher.decode(encrypted_proxy_url=str(raw["encrypted_proxy_url"]))
1714
+
1715
+ async def due_candidates(
1716
+ self,
1717
+ *,
1718
+ now: datetime,
1719
+ limit: int,
1720
+ ) -> tuple[ProxyCandidateView, ...]:
1721
+ """Read due candidates through `state_next_health_idx`.
1722
+
1723
+ Steps:
1724
+ 1. restrict to health-managed states and due timestamps;
1725
+ 2. order and bound through the compound index;
1726
+ 3. return credential-free candidate views.
1727
+
1728
+ Side effects if changes:
1729
+ - proxy health worker load and quarantine freshness.
1730
+ """
1731
+
1732
+ rows = (
1733
+ await self.proxies.find(
1734
+ {
1735
+ "state": {
1736
+ "$in": [
1737
+ ProxyCandidateState.QUARANTINED.value,
1738
+ ProxyCandidateState.HEALTHY_EXPERIMENTAL.value,
1739
+ ProxyCandidateState.DEGRADED.value,
1740
+ ]
1741
+ },
1742
+ "next_health_at": {"$lte": now},
1743
+ }
1744
+ )
1745
+ .sort("next_health_at", ASCENDING)
1746
+ .limit(limit)
1747
+ .to_list(length=limit)
1748
+ )
1749
+ return tuple(self._proxy_view(self._proxy_model(row)) for row in rows)
1750
+
1751
+ async def record_health(
1752
+ self,
1753
+ *,
1754
+ candidate_id: UUID,
1755
+ observation: ProxyHealthObservation,
1756
+ next_health_at: datetime,
1757
+ ) -> ProxyCandidateView:
1758
+ """Append bounded health history and derive promotion/decay state.
1759
+
1760
+ Steps:
1761
+ 1. load and validate the current candidate row;
1762
+ 2. append one observation within the bounded history window;
1763
+ 3. derive score/state and persist the next check time.
1764
+
1765
+ Side effects if changes:
1766
+ - experimental route eligibility and proxy trust.
1767
+ """
1768
+
1769
+ current = await self.proxies.find_one({"_id": candidate_id})
1770
+ if current is None:
1771
+ raise self._not_found("proxy candidate")
1772
+ model = self._proxy_model(current)
1773
+ recent = [*model.observations[-19:], observation]
1774
+ success_count = sum(1 for item in recent if item.succeeded and item.tls_valid)
1775
+ score = (Decimal(success_count) / Decimal(len(recent))).quantize(
1776
+ Decimal("0.001")
1777
+ )
1778
+ state = (
1779
+ ProxyCandidateState.HEALTHY_EXPERIMENTAL
1780
+ if len(recent) >= 5 and score >= Decimal("0.8")
1781
+ else ProxyCandidateState.DEGRADED
1782
+ if len(recent) >= 5
1783
+ else ProxyCandidateState.QUARANTINED
1784
+ )
1785
+ raw = await self.proxies.find_one_and_update(
1786
+ {"_id": candidate_id},
1787
+ {
1788
+ "$set": {
1789
+ "observations": [
1790
+ item.model_dump(mode="python", by_alias=False)
1791
+ for item in recent
1792
+ ],
1793
+ "health_score": score,
1794
+ "state": state.value,
1795
+ "next_health_at": next_health_at,
1796
+ "updated_at": utc_now(),
1797
+ }
1798
+ },
1799
+ return_document=ReturnDocument.AFTER,
1800
+ )
1801
+ if raw is None:
1802
+ raise self._not_found("proxy candidate")
1803
+ return self._proxy_view(self._proxy_model(raw))
1804
+
1805
+ async def append(self, *, event: ScrapeAuditEventMongoObject) -> None:
1806
+ """Insert one immutable audit fact with its absolute retention clock.
1807
+
1808
+ Steps:
1809
+ 1. derive the expiry solely from the server-owned occurrence time;
1810
+ 2. preserve the immutable caller/action/reason evidence;
1811
+ 3. insert the exact audit identity once.
1812
+
1813
+ Side effects if changes:
1814
+ - compliance records remain queryable for the configured audit window;
1815
+ - bounded maintenance eventually removes only expired audit facts.
1816
+ """
1817
+
1818
+ stored = event.model_copy(
1819
+ update={
1820
+ "history_expires_at": (
1821
+ event.occurred_at + self._audit_history_retention
1822
+ )
1823
+ }
1824
+ )
1825
+ await self.audit.insert_one(
1826
+ {
1827
+ "_id": stored.audit_id,
1828
+ **stored.model_dump(mode="python", by_alias=False),
1829
+ }
1830
+ )
1831
+
1832
+ async def lease_callback_due(
1833
+ self,
1834
+ *,
1835
+ worker_id: str,
1836
+ now: datetime,
1837
+ lease_seconds: int,
1838
+ ) -> ScrapeOutboxMongoObject | None:
1839
+ """Lease one due callback inside its immutable delivery budget.
1840
+
1841
+ Steps:
1842
+ 1. select pending due work below its stored attempt limit;
1843
+ 2. assign an exact worker owner and expiry;
1844
+ 3. increment the durable callback attempt ordinal atomically.
1845
+
1846
+ Side effects if changes:
1847
+ - webhook delivery concurrency and replay identity.
1848
+ """
1849
+
1850
+ raw = await self.outbox.find_one_and_update(
1851
+ {
1852
+ "state": OutboxState.PENDING.value,
1853
+ "next_attempt_at": {"$lte": now},
1854
+ "$expr": {"$lt": ["$attempt_count", "$attempt_limit"]},
1855
+ },
1856
+ {
1857
+ "$set": {
1858
+ "state": OutboxState.LEASED.value,
1859
+ "lease_owner": worker_id,
1860
+ "lease_expires_at": now + timedelta(seconds=lease_seconds),
1861
+ },
1862
+ "$inc": {"attempt_count": 1},
1863
+ },
1864
+ sort=[("next_attempt_at", ASCENDING)],
1865
+ return_document=ReturnDocument.AFTER,
1866
+ )
1867
+ return (
1868
+ ScrapeOutboxMongoObject.model_validate(self._without_id(raw))
1869
+ if raw is not None
1870
+ else None
1871
+ )
1872
+
1873
+ async def recover_expired_callback_leases(
1874
+ self,
1875
+ *,
1876
+ now: datetime,
1877
+ limit: int = 1_000,
1878
+ ) -> LeaseRecoverySummary:
1879
+ """Recover crashed callback owners or terminalize spent delivery budgets.
1880
+
1881
+ Steps:
1882
+ 1. read a bounded expired-lease batch through ``state_lease_expiry_idx``;
1883
+ 2. return rows below their immutable limit to pending state;
1884
+ 3. dead-letter rows whose last leased delivery spent the limit.
1885
+
1886
+ Side effects if changes:
1887
+ - crashed callback workers cannot strand terminal Shopify events forever;
1888
+ - callback delivery never exceeds its stored attempt budget.
1889
+ """
1890
+
1891
+ rows = (
1892
+ await self.outbox.find(
1893
+ {
1894
+ "state": OutboxState.LEASED.value,
1895
+ "lease_expires_at": {"$lte": now},
1896
+ }
1897
+ )
1898
+ .sort("lease_expires_at", ASCENDING)
1899
+ .limit(limit)
1900
+ .to_list(length=limit)
1901
+ )
1902
+ recovered_count = 0
1903
+ terminalized_count = 0
1904
+ for raw in rows:
1905
+ row = ScrapeOutboxMongoObject.model_validate(self._without_id(raw))
1906
+ dead_letter = row.attempt_count >= row.attempt_limit
1907
+ updated = await self.outbox.update_one(
1908
+ {
1909
+ "_id": row.event.event_id,
1910
+ "state": OutboxState.LEASED.value,
1911
+ "lease_owner": row.lease_owner,
1912
+ "lease_expires_at": {"$lte": now},
1913
+ "attempt_count": row.attempt_count,
1914
+ },
1915
+ {
1916
+ "$set": {
1917
+ "state": (
1918
+ OutboxState.DEAD_LETTER.value
1919
+ if dead_letter
1920
+ else OutboxState.PENDING.value
1921
+ ),
1922
+ "next_attempt_at": now,
1923
+ },
1924
+ "$unset": {"lease_owner": "", "lease_expires_at": ""},
1925
+ },
1926
+ )
1927
+ if dead_letter:
1928
+ terminalized_count += updated.modified_count
1929
+ else:
1930
+ recovered_count += updated.modified_count
1931
+ return LeaseRecoverySummary(
1932
+ recovered_count=recovered_count,
1933
+ terminalized_count=terminalized_count,
1934
+ )
1935
+
1936
+ async def mark_delivered(
1937
+ self,
1938
+ *,
1939
+ event_id: UUID,
1940
+ worker_id: str,
1941
+ delivered_at: datetime,
1942
+ ) -> None:
1943
+ """Mark one callback delivered and start dependent retention clocks.
1944
+
1945
+ Steps:
1946
+ 1. require the event to remain leased by the acknowledging worker;
1947
+ 2. retain the delivered callback until its independent replay horizon;
1948
+ 3. start terminal Job history retention only after durable delivery.
1949
+
1950
+ Side effects if changes:
1951
+ - duplicate callback prevention and callback replay evidence;
1952
+ - terminal Job/Attempt history becomes eligible for ordered purge.
1953
+ """
1954
+
1955
+ outbox_expires_at = delivered_at + self._delivered_outbox_retention
1956
+ job_expires_at = delivered_at + self._terminal_history_retention
1957
+ async with self._client.start_session() as session:
1958
+ async with await session.start_transaction(
1959
+ read_concern=ReadConcern("snapshot"),
1960
+ write_concern=WriteConcern("majority"),
1961
+ ):
1962
+ raw = await self.outbox.find_one_and_update(
1963
+ {
1964
+ "_id": event_id,
1965
+ "state": OutboxState.LEASED.value,
1966
+ "lease_owner": worker_id,
1967
+ },
1968
+ {
1969
+ "$set": {
1970
+ "state": OutboxState.DELIVERED.value,
1971
+ "delivered_at": delivered_at,
1972
+ "history_expires_at": outbox_expires_at,
1973
+ },
1974
+ "$unset": {"lease_owner": "", "lease_expires_at": ""},
1975
+ },
1976
+ return_document=ReturnDocument.AFTER,
1977
+ session=session,
1978
+ )
1979
+ if raw is None:
1980
+ raise self._late_acknowledgement()
1981
+ outbox = ScrapeOutboxMongoObject.model_validate(self._without_id(raw))
1982
+ job_update = await self.jobs.update_one(
1983
+ {
1984
+ "_id": outbox.event.job_id,
1985
+ "state": {
1986
+ "$in": [
1987
+ ScrapeJobState.SUCCEEDED.value,
1988
+ ScrapeJobState.FAILED.value,
1989
+ ScrapeJobState.CANCELLED.value,
1990
+ ScrapeJobState.DEAD_LETTER.value,
1991
+ ]
1992
+ },
1993
+ "history_expires_at": None,
1994
+ },
1995
+ {"$set": {"history_expires_at": job_expires_at}},
1996
+ session=session,
1997
+ )
1998
+ if job_update.modified_count != 1:
1999
+ raise RuntimeError(
2000
+ "delivered callback does not reference one retained terminal job"
2001
+ )
2002
+
2003
+ async def purge_expired_history(
2004
+ self,
2005
+ *,
2006
+ now: datetime,
2007
+ limit: int,
2008
+ ) -> HistoryRetentionSummary:
2009
+ """Delete one bounded dependency-ordered history batch atomically.
2010
+
2011
+ Steps:
2012
+ 1. acquire one expiring Mongo singleton lease without blocking a peer;
2013
+ 2. select each due collection through `history_expires_at_idx`;
2014
+ 3. delete Attempt children with terminal Job parents in one transaction;
2015
+ 4. delete independently expired delivered callbacks and audit facts;
2016
+ 5. release only the exact lease owner after the transaction commits.
2017
+
2018
+ Collection/query/index:
2019
+ - `scrape_jobs`, `scrape_outbox`, and `scrape_audit_events` use
2020
+ `history_expires_at_idx`; attempts use `job_attempt_number_unique_idx`.
2021
+
2022
+ Side effects if changes:
2023
+ - financial/source evidence and callback/audit replay horizons;
2024
+ - Mongo working-set size, backup volume, and maintenance throughput.
2025
+ """
2026
+
2027
+ if limit < 1:
2028
+ raise ValueError("history retention limit must be positive")
2029
+ lease_owner = uuid4()
2030
+ lease = await self.metadata.find_one_and_update(
2031
+ {
2032
+ "_id": _HISTORY_RETENTION_LEASE_ID,
2033
+ "lease_expires_at": {"$lte": now},
2034
+ },
2035
+ {
2036
+ "$set": {
2037
+ "lease_owner": lease_owner,
2038
+ "lease_expires_at": now + self._history_retention_lease,
2039
+ }
2040
+ },
2041
+ return_document=ReturnDocument.AFTER,
2042
+ )
2043
+ if lease is None:
2044
+ return HistoryRetentionSummary(lease_acquired=False)
2045
+
2046
+ jobs_deleted = 0
2047
+ attempts_deleted = 0
2048
+ delivered_outbox_deleted = 0
2049
+ audit_events_deleted = 0
2050
+ async with self._client.start_session() as session:
2051
+ async with await session.start_transaction(
2052
+ read_concern=ReadConcern("snapshot"),
2053
+ write_concern=WriteConcern("majority"),
2054
+ ):
2055
+ lease_fence = await self.metadata.update_one(
2056
+ {
2057
+ "_id": _HISTORY_RETENTION_LEASE_ID,
2058
+ "lease_owner": lease_owner,
2059
+ "lease_expires_at": {"$gt": utc_now()},
2060
+ },
2061
+ {"$inc": {"fence_revision": 1}},
2062
+ session=session,
2063
+ )
2064
+ if lease_fence.modified_count != 1:
2065
+ raise RuntimeError(
2066
+ "history retention lease expired or was replaced before purge"
2067
+ )
2068
+ job_rows = await (
2069
+ self.jobs.find(
2070
+ {
2071
+ "state": {
2072
+ "$in": [
2073
+ ScrapeJobState.SUCCEEDED.value,
2074
+ ScrapeJobState.FAILED.value,
2075
+ ScrapeJobState.CANCELLED.value,
2076
+ ScrapeJobState.DEAD_LETTER.value,
2077
+ ]
2078
+ },
2079
+ "history_expires_at": {"$lte": now},
2080
+ },
2081
+ {"_id": 1},
2082
+ session=session,
2083
+ )
2084
+ .sort("history_expires_at", ASCENDING)
2085
+ .limit(limit)
2086
+ .to_list(length=limit)
2087
+ )
2088
+ job_ids = [row["_id"] for row in job_rows]
2089
+ if job_ids:
2090
+ attempt_result = await self.attempts.delete_many(
2091
+ {"job_id": {"$in": job_ids}},
2092
+ session=session,
2093
+ )
2094
+ job_result = await self.jobs.delete_many(
2095
+ {
2096
+ "_id": {"$in": job_ids},
2097
+ "state": {
2098
+ "$in": [
2099
+ ScrapeJobState.SUCCEEDED.value,
2100
+ ScrapeJobState.FAILED.value,
2101
+ ScrapeJobState.CANCELLED.value,
2102
+ ScrapeJobState.DEAD_LETTER.value,
2103
+ ]
2104
+ },
2105
+ "history_expires_at": {"$lte": now},
2106
+ },
2107
+ session=session,
2108
+ )
2109
+ if job_result.deleted_count != len(job_ids):
2110
+ raise RuntimeError(
2111
+ "terminal history changed during atomic retention purge"
2112
+ )
2113
+ attempts_deleted = attempt_result.deleted_count
2114
+ jobs_deleted = job_result.deleted_count
2115
+
2116
+ outbox_rows = await (
2117
+ self.outbox.find(
2118
+ {
2119
+ "state": OutboxState.DELIVERED.value,
2120
+ "history_expires_at": {"$lte": now},
2121
+ },
2122
+ {"_id": 1},
2123
+ session=session,
2124
+ )
2125
+ .sort("history_expires_at", ASCENDING)
2126
+ .limit(limit)
2127
+ .to_list(length=limit)
2128
+ )
2129
+ outbox_ids = [row["_id"] for row in outbox_rows]
2130
+ if outbox_ids:
2131
+ outbox_result = await self.outbox.delete_many(
2132
+ {
2133
+ "_id": {"$in": outbox_ids},
2134
+ "state": OutboxState.DELIVERED.value,
2135
+ "history_expires_at": {"$lte": now},
2136
+ },
2137
+ session=session,
2138
+ )
2139
+ if outbox_result.deleted_count != len(outbox_ids):
2140
+ raise RuntimeError(
2141
+ "delivered callback history changed during atomic purge"
2142
+ )
2143
+ delivered_outbox_deleted = outbox_result.deleted_count
2144
+
2145
+ audit_rows = await (
2146
+ self.audit.find(
2147
+ {"history_expires_at": {"$lte": now}},
2148
+ {"_id": 1},
2149
+ session=session,
2150
+ )
2151
+ .sort("history_expires_at", ASCENDING)
2152
+ .limit(limit)
2153
+ .to_list(length=limit)
2154
+ )
2155
+ audit_ids = [row["_id"] for row in audit_rows]
2156
+ if audit_ids:
2157
+ audit_result = await self.audit.delete_many(
2158
+ {
2159
+ "_id": {"$in": audit_ids},
2160
+ "history_expires_at": {"$lte": now},
2161
+ },
2162
+ session=session,
2163
+ )
2164
+ if audit_result.deleted_count != len(audit_ids):
2165
+ raise RuntimeError(
2166
+ "audit history changed during atomic retention purge"
2167
+ )
2168
+ audit_events_deleted = audit_result.deleted_count
2169
+
2170
+ await self.metadata.update_one(
2171
+ {
2172
+ "_id": _HISTORY_RETENTION_LEASE_ID,
2173
+ "lease_owner": lease_owner,
2174
+ },
2175
+ {
2176
+ "$set": {"lease_expires_at": now},
2177
+ "$unset": {"lease_owner": ""},
2178
+ },
2179
+ )
2180
+ return HistoryRetentionSummary(
2181
+ lease_acquired=True,
2182
+ jobs_deleted=jobs_deleted,
2183
+ attempts_deleted=attempts_deleted,
2184
+ delivered_outbox_deleted=delivered_outbox_deleted,
2185
+ audit_events_deleted=audit_events_deleted,
2186
+ )
2187
+
2188
+ async def reschedule(
2189
+ self,
2190
+ *,
2191
+ event_id: UUID,
2192
+ worker_id: str,
2193
+ next_attempt_at: datetime,
2194
+ dead_letter: bool,
2195
+ ) -> None:
2196
+ """Reschedule one exact worker-owned callback or dead-letter it.
2197
+
2198
+ Steps:
2199
+ 1. require the current lease owner before any state change;
2200
+ 2. retain the supplied durable retry horizon or terminal decision;
2201
+ 3. reject stale owners after recovery or replacement.
2202
+
2203
+ Side effects if changes:
2204
+ - callback retry cadence and operational gaps.
2205
+ """
2206
+
2207
+ updated = await self.outbox.update_one(
2208
+ {
2209
+ "_id": event_id,
2210
+ "state": OutboxState.LEASED.value,
2211
+ "lease_owner": worker_id,
2212
+ },
2213
+ {
2214
+ "$set": {
2215
+ "state": (
2216
+ OutboxState.DEAD_LETTER.value
2217
+ if dead_letter
2218
+ else OutboxState.PENDING.value
2219
+ ),
2220
+ "next_attempt_at": next_attempt_at,
2221
+ },
2222
+ "$unset": {"lease_owner": "", "lease_expires_at": ""},
2223
+ },
2224
+ )
2225
+ if updated.modified_count != 1:
2226
+ raise self._late_acknowledgement()
2227
+
2228
+ @staticmethod
2229
+ def _job_document(job: ScrapeJobMongoObject) -> dict[str, object]:
2230
+ """Convert the Pydantic job into its primary-key Mongo document.
2231
+
2232
+ Side effects if changes:
2233
+ - canonical job storage shape.
2234
+ """
2235
+
2236
+ document = job.model_dump(
2237
+ mode="python",
2238
+ by_alias=False,
2239
+ exclude={"create"},
2240
+ )
2241
+ document["create"] = job.create.model_dump(mode="json", by_alias=False)
2242
+ return {"_id": job.job_id, **document}
2243
+
2244
+ @staticmethod
2245
+ def _job_model(raw: Mapping[str, Any]) -> ScrapeJobMongoObject:
2246
+ """Validate one Mongo job document before application use.
2247
+
2248
+ Steps:
2249
+ 1. remove the storage-only `_id` duplicate;
2250
+ 2. validate every lifecycle/budget field through Pydantic;
2251
+ 3. return the canonical persistence model.
2252
+
2253
+ Side effects if changes:
2254
+ - corruption detection on every job read.
2255
+ """
2256
+
2257
+ return ScrapeJobMongoObject.model_validate(
2258
+ MongoScraperRepository._without_id(raw)
2259
+ )
2260
+
2261
+ @staticmethod
2262
+ def _artifact_model(raw: Mapping[str, Any]) -> ScrapeArtifactMongoObject:
2263
+ """Validate one artifact metadata document before retention decisions.
2264
+
2265
+ Side effects if changes:
2266
+ - pin, cleanup lease, and private object consistency checks.
2267
+ """
2268
+
2269
+ return ScrapeArtifactMongoObject.model_validate(
2270
+ MongoScraperRepository._without_id(raw)
2271
+ )
2272
+
2273
+ @staticmethod
2274
+ def _proxy_model(raw: Mapping[str, Any]) -> ProxyCandidateMongoObject:
2275
+ """Validate one encrypted proxy Mongo document.
2276
+
2277
+ Steps:
2278
+ 1. remove the storage-only `_id` duplicate;
2279
+ 2. validate encrypted/redacted/health fields;
2280
+ 3. return the canonical persistence model.
2281
+
2282
+ Side effects if changes:
2283
+ - proxy list, health, and route selection.
2284
+ """
2285
+
2286
+ return ProxyCandidateMongoObject.model_validate(
2287
+ MongoScraperRepository._without_id(raw)
2288
+ )
2289
+
2290
+ @staticmethod
2291
+ def _proxy_view(model: ProxyCandidateMongoObject) -> ProxyCandidateView:
2292
+ """Drop encrypted proxy material from the operational view.
2293
+
2294
+ Side effects if changes:
2295
+ - API and frontend credential redaction.
2296
+ """
2297
+
2298
+ return ProxyCandidateView(
2299
+ candidate_id=model.candidate_id,
2300
+ endpoint_redacted=model.endpoint_redacted,
2301
+ source=model.source,
2302
+ source_reference=model.source_reference,
2303
+ state=model.state,
2304
+ health_score=model.health_score,
2305
+ observations=tuple(model.observations),
2306
+ created_at=model.created_at,
2307
+ )
2308
+
2309
+ @staticmethod
2310
+ def _without_id(raw: Mapping[str, Any]) -> dict[str, Any]:
2311
+ """Copy a Mongo document without its storage-only `_id` key.
2312
+
2313
+ Side effects if changes:
2314
+ - every persistence-to-Pydantic conversion.
2315
+ """
2316
+
2317
+ output = dict(raw)
2318
+ output.pop("_id", None)
2319
+ return output
2320
+
2321
+ @staticmethod
2322
+ def _not_found(object_name: str) -> ScraperDomainError:
2323
+ """Build the shared typed missing-object failure.
2324
+
2325
+ Side effects if changes:
2326
+ - API 404 mapping and worker diagnostics.
2327
+ """
2328
+
2329
+ return ScraperDomainError(
2330
+ code=ScraperErrorCode.NOT_FOUND,
2331
+ message=f"{object_name} was not found",
2332
+ )
2333
+
2334
+ @staticmethod
2335
+ def _late_acknowledgement() -> ScraperDomainError:
2336
+ """Build the typed stale-worker acknowledgement failure.
2337
+
2338
+ Side effects if changes:
2339
+ - duplicate completion and cancellation race safety.
2340
+ """
2341
+
2342
+ return ScraperDomainError(
2343
+ code=ScraperErrorCode.REVISION_CONFLICT,
2344
+ message="job lease is stale or no longer owns this transition",
2345
+ )