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,743 @@
1
+ """Inspect or clear the incompatible first-release Scraper namespace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import ArgumentParser
6
+ from asyncio import run
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from enum import StrEnum
11
+ from json import dumps
12
+ from pathlib import Path
13
+ from typing import Protocol, cast
14
+
15
+ from pymongo import ASCENDING, AsyncMongoClient
16
+ from pymongo.asynchronous.collection import AsyncCollection
17
+ from redis.asyncio import Redis
18
+
19
+ from keble_scraper_api.container import build_artifact_store
20
+ from keble_scraper_api.protocols import ArtifactStoreProtocol
21
+ from keble_scraper_api.settings import RuntimeEnvironment, ScraperSettings
22
+
23
+
24
+ RESET_CONFIRMATION = "RESET_SCRAPER_PRE_RELEASE_STATE"
25
+ WRITER_STOP_CONFIRMATION = "I_STOPPED_SCRAPER_WRITERS"
26
+ SAFE_ENVIRONMENTS = frozenset({RuntimeEnvironment.DEVELOPMENT, RuntimeEnvironment.TEST})
27
+ DELETE_BATCH_SIZE = 1_000
28
+ RESET_COLLECTIONS = (
29
+ "scrape_outbox",
30
+ "scrape_attempts",
31
+ "scrape_jobs",
32
+ "scrape_cache_entries",
33
+ "scrape_artifact_pins",
34
+ "scrape_artifacts",
35
+ "scrape_audit_events",
36
+ "proxy_candidates",
37
+ "scrape_metadata",
38
+ )
39
+ ROLLBACK_CONDITION = (
40
+ "Stop Shopify/Scraper release cutover and restore the recorded Mongo/Redis "
41
+ "snapshots plus artifact-prefix backup when startup ownership fencing fails, "
42
+ "a new job cannot complete one callback, or artifact cleanup counts differ."
43
+ )
44
+
45
+
46
+ class ScraperResetConfigurationError(ValueError):
47
+ """Refuse a clean break whose local safety contract is incomplete.
48
+
49
+ Steps:
50
+ 1. distinguish operator refusal from provider or persistence faults.
51
+
52
+ Side effects if changes:
53
+ - release automation treats this as a no-authority/no-mutation outcome.
54
+ """
55
+
56
+
57
+ class ResetMode(StrEnum):
58
+ """Finite write-free and destructive command modes.
59
+
60
+ Steps:
61
+ 1. represent inspection separately from application;
62
+ 2. prevent nullable flags or strings from granting mutation authority.
63
+
64
+ Side effects if changes:
65
+ - only APPLY may delete binary, Mongo, or Redis state.
66
+ """
67
+
68
+ DRY_RUN = "DRY_RUN"
69
+ APPLY = "APPLY"
70
+
71
+
72
+ class MongoResetProtocol(Protocol):
73
+ """Narrow configured-database surface used by the reset.
74
+
75
+ Steps:
76
+ 1. prove connectivity before collection reads;
77
+ 2. expose only exact named collections from one database.
78
+
79
+ Side effects if changes:
80
+ - the command must never discover/drop an adjacent database or collection.
81
+ """
82
+
83
+ async def command(self, command: str) -> dict[str, object]:
84
+ """Execute the bounded connectivity command.
85
+
86
+ Steps:
87
+ 1. prove the configured database client is reachable.
88
+
89
+ Side effects if changes:
90
+ - no inspection or mutation may proceed after failure.
91
+ """
92
+
93
+ ...
94
+
95
+ def __getitem__(self, name: str) -> AsyncCollection[dict[str, object]]:
96
+ """Bind one explicitly allowlisted collection.
97
+
98
+ Steps:
99
+ 1. return the exact named collection without database discovery.
100
+
101
+ Side effects if changes:
102
+ - reset scope remains controlled by `RESET_COLLECTIONS`.
103
+ """
104
+
105
+ ...
106
+
107
+
108
+ class RedisResetProtocol(Protocol):
109
+ """Narrow exact-namespace Redis surface used by the reset.
110
+
111
+ Steps:
112
+ 1. prove connectivity;
113
+ 2. stream exact-prefix keys and delete bounded batches;
114
+ 3. close the command-owned pool.
115
+
116
+ Side effects if changes:
117
+ - unrelated Redis namespaces must survive the clean break.
118
+ """
119
+
120
+ async def ping(self) -> bool:
121
+ """Prove Redis is reachable before inspection.
122
+
123
+ Steps:
124
+ 1. execute the adapter connectivity command.
125
+
126
+ Side effects if changes:
127
+ - no Mongo or binary deletion may begin after failure.
128
+ """
129
+
130
+ ...
131
+
132
+ def scan_iter(self, *, match: str) -> AsyncIterator[str]:
133
+ """Stream keys matching one exact namespace prefix.
134
+
135
+ Steps:
136
+ 1. yield identities without reading or logging values.
137
+
138
+ Side effects if changes:
139
+ - count and deletion scopes share this iterator.
140
+ """
141
+
142
+ ...
143
+
144
+ async def delete(self, *names: str) -> int:
145
+ """Delete one bounded opaque key batch.
146
+
147
+ Steps:
148
+ 1. remove only the exact supplied identities.
149
+
150
+ Side effects if changes:
151
+ - APPLY totals and idempotency depend on the returned count.
152
+ """
153
+
154
+ ...
155
+
156
+ async def aclose(self) -> None:
157
+ """Close the command-owned Redis pool.
158
+
159
+ Steps:
160
+ 1. release all connections after evidence is complete.
161
+
162
+ Side effects if changes:
163
+ - repeated maintenance invocations must not leak connections.
164
+ """
165
+
166
+ ...
167
+
168
+
169
+ @dataclass(frozen=True, slots=True)
170
+ class ResetOptions:
171
+ """Validated secret-free authority for one clean-break campaign.
172
+
173
+ Steps:
174
+ 1. retain finite mode and exact safety acknowledgements;
175
+ 2. retain database, Redis, artifact-owner, backup, and audit identities;
176
+ 3. exclude credentials, fetched bodies, and provider payloads.
177
+
178
+ Side effects if changes:
179
+ - APPLY authorization and rollback evidence depend on these exact fields.
180
+ """
181
+
182
+ mode: ResetMode
183
+ confirmation: str | None
184
+ writer_confirmation: str | None
185
+ expected_database: str | None
186
+ expected_redis_namespace: str | None
187
+ expected_artifact_owner: str | None
188
+ rollback_reference: str | None
189
+ artifact_inventory_reference: str | None
190
+ audit_output: Path | None
191
+ campaign_id: str
192
+ reason: str
193
+
194
+
195
+ @dataclass(frozen=True, slots=True)
196
+ class ResetReport:
197
+ """Count-only inspection or deletion evidence for one campaign.
198
+
199
+ Steps:
200
+ 1. retain exact Mongo, Redis, and binary counts;
201
+ 2. disclose legacy retention populations separately;
202
+ 3. retain rollback/inventory evidence without object identities.
203
+
204
+ Side effects if changes:
205
+ - stdout and immutable audit output consume this stable shape.
206
+ """
207
+
208
+ mode: ResetMode
209
+ mongo_counts: dict[str, int]
210
+ redis_key_count: int
211
+ artifact_object_count: int
212
+ jobs_without_history_clock: int
213
+ delivered_outbox_without_history_clock: int
214
+ audits_without_history_clock: int
215
+ rollback_reference: str | None
216
+ artifact_inventory_reference: str | None
217
+ rollback_condition: str = ROLLBACK_CONDITION
218
+
219
+ def total_mongo_rows(self) -> int:
220
+ """Return the exact sum of allowlisted Mongo counts.
221
+
222
+ Steps:
223
+ 1. sum the authoritative per-collection integer counts once.
224
+
225
+ Side effects if changes:
226
+ - per-collection counts remain authoritative for reconciliation.
227
+ """
228
+
229
+ return sum(self.mongo_counts.values())
230
+
231
+
232
+ def parse_args(*, argv: Sequence[str] | None = None) -> ResetOptions:
233
+ """Parse a DRY_RUN-first command without credential arguments.
234
+
235
+ Steps:
236
+ 1. retain exact target, writer, backup, owner, and audit fields;
237
+ 2. validate bounded campaign and reason strings;
238
+ 3. select APPLY only from the explicit flag.
239
+
240
+ Side effects if changes:
241
+ - runbooks depend on these literal flags and confirmation values.
242
+ """
243
+
244
+ parser = ArgumentParser(description=__doc__)
245
+ parser.add_argument("--apply", action="store_true")
246
+ parser.add_argument("--confirm")
247
+ parser.add_argument("--writers-stopped")
248
+ parser.add_argument("--expected-database")
249
+ parser.add_argument("--expected-redis-namespace")
250
+ parser.add_argument("--expected-artifact-owner")
251
+ parser.add_argument("--rollback-reference")
252
+ parser.add_argument("--artifact-inventory-reference")
253
+ parser.add_argument("--audit-output", type=Path)
254
+ parser.add_argument("--campaign-id", default="review2-wp8-scraper-reset")
255
+ parser.add_argument(
256
+ "--reason",
257
+ default="REVIEW2 WP8 first-release Scraper clean break",
258
+ )
259
+ parsed = parser.parse_args(argv)
260
+ campaign_id = str(parsed.campaign_id).strip()
261
+ reason = str(parsed.reason).strip()
262
+ if len(campaign_id) < 8 or len(campaign_id) > 96:
263
+ raise ValueError("--campaign-id must contain 8..96 characters")
264
+ if len(reason) < 8 or len(reason) > 1_000:
265
+ raise ValueError("--reason must contain 8..1000 characters")
266
+ return ResetOptions(
267
+ mode=ResetMode.APPLY if bool(parsed.apply) else ResetMode.DRY_RUN,
268
+ confirmation=None if parsed.confirm is None else str(parsed.confirm),
269
+ writer_confirmation=(
270
+ None if parsed.writers_stopped is None else str(parsed.writers_stopped)
271
+ ),
272
+ expected_database=(
273
+ None if parsed.expected_database is None else str(parsed.expected_database)
274
+ ),
275
+ expected_redis_namespace=(
276
+ None
277
+ if parsed.expected_redis_namespace is None
278
+ else str(parsed.expected_redis_namespace)
279
+ ),
280
+ expected_artifact_owner=(
281
+ None
282
+ if parsed.expected_artifact_owner is None
283
+ else str(parsed.expected_artifact_owner)
284
+ ),
285
+ rollback_reference=(
286
+ None
287
+ if parsed.rollback_reference is None
288
+ else str(parsed.rollback_reference).strip()
289
+ ),
290
+ artifact_inventory_reference=(
291
+ None
292
+ if parsed.artifact_inventory_reference is None
293
+ else str(parsed.artifact_inventory_reference).strip()
294
+ ),
295
+ audit_output=parsed.audit_output,
296
+ campaign_id=campaign_id,
297
+ reason=reason,
298
+ )
299
+
300
+
301
+ def assert_reset_authorized(
302
+ *,
303
+ settings: ScraperSettings,
304
+ options: ResetOptions,
305
+ ) -> None:
306
+ """Refuse ambiguous or production-like deletion before clients open.
307
+
308
+ Steps:
309
+ 1. allow write-free DRY_RUN without acknowledgements;
310
+ 2. reject APPLY outside development/test;
311
+ 3. require exact writer, database, Redis, owner, backup, inventory, and audit evidence.
312
+
313
+ Side effects if changes:
314
+ - production-like Scraper data cannot be deleted by this command.
315
+ """
316
+
317
+ if options.mode is ResetMode.DRY_RUN:
318
+ return
319
+ if settings.environment not in SAFE_ENVIRONMENTS:
320
+ raise ScraperResetConfigurationError(
321
+ "APPLY is permanently disabled for production-like environments"
322
+ )
323
+ if options.confirmation != RESET_CONFIRMATION:
324
+ raise ScraperResetConfigurationError(
325
+ f"--apply requires --confirm {RESET_CONFIRMATION}"
326
+ )
327
+ if options.writer_confirmation != WRITER_STOP_CONFIRMATION:
328
+ raise ScraperResetConfigurationError(
329
+ f"--apply requires --writers-stopped {WRITER_STOP_CONFIRMATION}"
330
+ )
331
+ if options.expected_database != settings.mongo_database:
332
+ raise ScraperResetConfigurationError(
333
+ "--expected-database must exactly match Scraper settings"
334
+ )
335
+ if options.expected_redis_namespace != settings.redis_namespace:
336
+ raise ScraperResetConfigurationError(
337
+ "--expected-redis-namespace must exactly match Scraper settings"
338
+ )
339
+ if options.expected_artifact_owner != settings.resolved_artifact_namespace_owner:
340
+ raise ScraperResetConfigurationError(
341
+ "--expected-artifact-owner must exactly match Scraper settings"
342
+ )
343
+ if options.rollback_reference is None or len(options.rollback_reference) < 8:
344
+ raise ScraperResetConfigurationError(
345
+ "--apply requires a non-empty --rollback-reference"
346
+ )
347
+ if (
348
+ options.artifact_inventory_reference is None
349
+ or len(options.artifact_inventory_reference) < 8
350
+ ):
351
+ raise ScraperResetConfigurationError(
352
+ "--apply requires an artifact namespace inventory/backup reference"
353
+ )
354
+ if options.audit_output is None:
355
+ raise ScraperResetConfigurationError(
356
+ "--apply requires an immutable --audit-output path"
357
+ )
358
+
359
+
360
+ async def _redis_key_count(
361
+ *,
362
+ redis: RedisResetProtocol,
363
+ namespace: str,
364
+ ) -> int:
365
+ """Count one exact Redis namespace without reading values.
366
+
367
+ Steps:
368
+ 1. scan only `namespace:*`;
369
+ 2. increment a scalar without retaining identities.
370
+
371
+ Side effects if changes:
372
+ - DRY_RUN remains bounded in memory even for a large namespace.
373
+ """
374
+
375
+ count = 0
376
+ async for _ in redis.scan_iter(match=f"{namespace}:*"):
377
+ count += 1
378
+ return count
379
+
380
+
381
+ async def inspect_state(
382
+ *,
383
+ database: MongoResetProtocol,
384
+ redis: RedisResetProtocol,
385
+ settings: ScraperSettings,
386
+ options: ResetOptions,
387
+ ) -> ResetReport:
388
+ """Inspect exact clean-break populations without fetching stored bodies.
389
+
390
+ Steps:
391
+ 1. prove Mongo and Redis connectivity;
392
+ 2. count every allowlisted collection and exact Redis namespace;
393
+ 3. count missing retention clocks and artifact metadata rows separately.
394
+
395
+ Side effects if changes:
396
+ - DRY_RUN and APPLY preflight share these count semantics.
397
+ """
398
+
399
+ await database.command("ping")
400
+ await redis.ping()
401
+ counts = {
402
+ name: await database[name].count_documents({}) for name in RESET_COLLECTIONS
403
+ }
404
+ jobs_without_clock = await database["scrape_jobs"].count_documents(
405
+ {"history_expires_at": {"$exists": False}}
406
+ )
407
+ outbox_without_clock = await database["scrape_outbox"].count_documents(
408
+ {
409
+ "state": "DELIVERED",
410
+ "history_expires_at": {"$exists": False},
411
+ }
412
+ )
413
+ audits_without_clock = await database["scrape_audit_events"].count_documents(
414
+ {"history_expires_at": {"$exists": False}}
415
+ )
416
+ return ResetReport(
417
+ mode=options.mode,
418
+ mongo_counts=counts,
419
+ redis_key_count=await _redis_key_count(
420
+ redis=redis,
421
+ namespace=settings.redis_namespace,
422
+ ),
423
+ artifact_object_count=counts["scrape_artifacts"],
424
+ jobs_without_history_clock=jobs_without_clock,
425
+ delivered_outbox_without_history_clock=outbox_without_clock,
426
+ audits_without_history_clock=audits_without_clock,
427
+ rollback_reference=options.rollback_reference,
428
+ artifact_inventory_reference=options.artifact_inventory_reference,
429
+ )
430
+
431
+
432
+ async def iter_artifact_object_keys(
433
+ *,
434
+ database: MongoResetProtocol,
435
+ ) -> AsyncIterator[str]:
436
+ """Stream exact persisted object keys without materializing the corpus.
437
+
438
+ Steps:
439
+ 1. read only object-key and identity fields from artifact metadata;
440
+ 2. order by stable Mongo identity and request bounded server batches;
441
+ 3. yield validated non-empty string keys one at a time.
442
+
443
+ Side effects if changes:
444
+ - binary deletion must finish before artifact metadata is removed.
445
+ """
446
+
447
+ cursor = (
448
+ database["scrape_artifacts"]
449
+ .find({}, {"_id": 1, "object_key": 1})
450
+ .sort("_id", ASCENDING)
451
+ .batch_size(DELETE_BATCH_SIZE)
452
+ )
453
+ async for row in cursor:
454
+ key = row.get("object_key")
455
+ if not isinstance(key, str) or not key:
456
+ raise ScraperResetConfigurationError(
457
+ "scrape_artifacts contains an invalid object_key"
458
+ )
459
+ yield key
460
+
461
+
462
+ async def _delete_artifact_objects(
463
+ *,
464
+ store: ArtifactStoreProtocol,
465
+ object_keys: AsyncIterator[str],
466
+ ) -> int:
467
+ """Delete referenced binary objects in bounded batches before metadata.
468
+
469
+ Steps:
470
+ 1. accumulate at most the fixed batch size;
471
+ 2. delete each exact key batch through the canonical artifact store;
472
+ 3. return the number of referenced keys submitted successfully.
473
+
474
+ Side effects if changes:
475
+ - a storage failure leaves Mongo metadata intact for safe replay.
476
+ """
477
+
478
+ deleted = 0
479
+ batch: list[str] = []
480
+ async for key in object_keys:
481
+ batch.append(key)
482
+ if len(batch) == DELETE_BATCH_SIZE:
483
+ await store.delete_many(object_keys=batch)
484
+ deleted += len(batch)
485
+ batch = []
486
+ if batch:
487
+ await store.delete_many(object_keys=batch)
488
+ deleted += len(batch)
489
+ return deleted
490
+
491
+
492
+ async def _delete_redis_namespace(
493
+ *,
494
+ redis: RedisResetProtocol,
495
+ namespace: str,
496
+ ) -> int:
497
+ """Delete one exact Redis namespace in bounded batches.
498
+
499
+ Steps:
500
+ 1. scan only `namespace:*` identities;
501
+ 2. delete each bounded batch and aggregate exact counts;
502
+ 3. leave every adjacent key untouched.
503
+
504
+ Side effects if changes:
505
+ - rate/budget/dispatch state restarts empty only inside the Scraper namespace.
506
+ """
507
+
508
+ deleted = 0
509
+ batch: list[str] = []
510
+ async for key in redis.scan_iter(match=f"{namespace}:*"):
511
+ batch.append(key)
512
+ if len(batch) == DELETE_BATCH_SIZE:
513
+ deleted += await redis.delete(*batch)
514
+ batch = []
515
+ if batch:
516
+ deleted += await redis.delete(*batch)
517
+ return deleted
518
+
519
+
520
+ async def apply_reset(
521
+ *,
522
+ database: MongoResetProtocol,
523
+ redis: RedisResetProtocol,
524
+ store: ArtifactStoreProtocol,
525
+ object_keys: AsyncIterator[str],
526
+ settings: ScraperSettings,
527
+ inspected: ResetReport,
528
+ ) -> ResetReport:
529
+ """Delete binaries, dependent Mongo rows, and exact Redis state in order.
530
+
531
+ Steps:
532
+ 1. delete every referenced binary while Mongo recovery metadata still exists;
533
+ 2. delete dependent Mongo rows in the fixed allowlist order;
534
+ 3. delete only the exact Redis namespace and return replay-safe counts.
535
+
536
+ Side effects if changes:
537
+ - interrupted binary deletion is retryable because metadata remains;
538
+ - startup rebuilds indexes/ownership metadata after all writers remain stopped.
539
+ """
540
+
541
+ artifact_deleted = await _delete_artifact_objects(
542
+ store=store,
543
+ object_keys=object_keys,
544
+ )
545
+ deleted: dict[str, int] = {}
546
+ for name in RESET_COLLECTIONS:
547
+ deleted[name] = (await database[name].delete_many({})).deleted_count
548
+ redis_deleted = await _delete_redis_namespace(
549
+ redis=redis,
550
+ namespace=settings.redis_namespace,
551
+ )
552
+ return ResetReport(
553
+ mode=ResetMode.APPLY,
554
+ mongo_counts=deleted,
555
+ redis_key_count=redis_deleted,
556
+ artifact_object_count=artifact_deleted,
557
+ jobs_without_history_clock=inspected.jobs_without_history_clock,
558
+ delivered_outbox_without_history_clock=(
559
+ inspected.delivered_outbox_without_history_clock
560
+ ),
561
+ audits_without_history_clock=inspected.audits_without_history_clock,
562
+ rollback_reference=inspected.rollback_reference,
563
+ artifact_inventory_reference=inspected.artifact_inventory_reference,
564
+ )
565
+
566
+
567
+ def _payload(
568
+ *,
569
+ options: ResetOptions,
570
+ report: ResetReport,
571
+ status: str,
572
+ ) -> dict[str, object]:
573
+ """Build stable credential/body-free stdout and audit evidence.
574
+
575
+ Steps:
576
+ 1. bind campaign identity/reason to exact deletion counts;
577
+ 2. retain rollback and artifact inventory references;
578
+ 3. stamp UTC without object keys, bodies, URLs, proxies, or credentials.
579
+
580
+ Side effects if changes:
581
+ - release reconciliation and rollback review consume this shape.
582
+ """
583
+
584
+ return {
585
+ "campaignId": options.campaign_id,
586
+ "reason": options.reason,
587
+ "mode": report.mode.value,
588
+ "status": status,
589
+ "mongoCounts": report.mongo_counts,
590
+ "mongoTotal": report.total_mongo_rows(),
591
+ "redisKeyCount": report.redis_key_count,
592
+ "artifactObjectCount": report.artifact_object_count,
593
+ "jobsWithoutHistoryClock": report.jobs_without_history_clock,
594
+ "deliveredOutboxWithoutHistoryClock": (
595
+ report.delivered_outbox_without_history_clock
596
+ ),
597
+ "auditsWithoutHistoryClock": report.audits_without_history_clock,
598
+ "rollbackReference": report.rollback_reference,
599
+ "artifactInventoryReference": report.artifact_inventory_reference,
600
+ "rollbackCondition": report.rollback_condition,
601
+ "recordedAt": datetime.now(timezone.utc).isoformat(),
602
+ }
603
+
604
+
605
+ def start_audit(*, options: ResetOptions) -> Path:
606
+ """Create exclusive RUNNING evidence before the first external deletion.
607
+
608
+ Steps:
609
+ 1. derive a sibling running path from the final destination;
610
+ 2. create it exclusively with target/rollback authority only;
611
+ 3. return the owned path for terminal promotion.
612
+
613
+ Side effects if changes:
614
+ - interrupted APPLY intentionally leaves a `.running.json` artifact.
615
+ """
616
+
617
+ if options.audit_output is None:
618
+ raise ScraperResetConfigurationError("audit output is required")
619
+ options.audit_output.parent.mkdir(parents=True, exist_ok=True)
620
+ running_path = options.audit_output.with_suffix(
621
+ options.audit_output.suffix + ".running.json"
622
+ )
623
+ with running_path.open("x", encoding="utf-8") as handle:
624
+ handle.write(
625
+ dumps(
626
+ {
627
+ "campaignId": options.campaign_id,
628
+ "reason": options.reason,
629
+ "mode": options.mode.value,
630
+ "status": "RUNNING",
631
+ "rollbackReference": options.rollback_reference,
632
+ "artifactInventoryReference": (
633
+ options.artifact_inventory_reference
634
+ ),
635
+ "rollbackCondition": ROLLBACK_CONDITION,
636
+ "recordedAt": datetime.now(timezone.utc).isoformat(),
637
+ },
638
+ sort_keys=True,
639
+ )
640
+ + "\n"
641
+ )
642
+ return running_path
643
+
644
+
645
+ def complete_audit(
646
+ *,
647
+ options: ResetOptions,
648
+ report: ResetReport,
649
+ running_path: Path,
650
+ ) -> None:
651
+ """Atomically promote RUNNING evidence to the final count-only audit.
652
+
653
+ Steps:
654
+ 1. reject an existing final destination;
655
+ 2. replace only the owned running content with terminal counts;
656
+ 3. atomically rename it to the immutable final path.
657
+
658
+ Side effects if changes:
659
+ - successful APPLY leaves one final artifact and no RUNNING ambiguity.
660
+ """
661
+
662
+ if options.audit_output is None:
663
+ raise ScraperResetConfigurationError("audit output is required")
664
+ if options.audit_output.exists():
665
+ raise FileExistsError(options.audit_output)
666
+ running_path.write_text(
667
+ dumps(
668
+ _payload(options=options, report=report, status="SUCCEEDED"), sort_keys=True
669
+ )
670
+ + "\n",
671
+ encoding="utf-8",
672
+ )
673
+ running_path.replace(options.audit_output)
674
+
675
+
676
+ async def amain(*, argv: Sequence[str] | None = None) -> None:
677
+ """Inspect or apply the exact Scraper reset and print safe evidence.
678
+
679
+ Steps:
680
+ 1. parse/authorize before clients open and inspect dependencies;
681
+ 2. on APPLY, create RUNNING audit and prove artifact namespace ownership;
682
+ 3. delete referenced binaries, Mongo rows, and exact Redis keys in order;
683
+ 4. close clients, promote audit, and print the same count-only evidence.
684
+
685
+ Side effects if changes:
686
+ - APPLY deletes only the configured local/test Scraper namespace;
687
+ - unexpected faults propagate and preserve metadata or RUNNING evidence.
688
+ """
689
+
690
+ options = parse_args(argv=argv)
691
+ settings = ScraperSettings()
692
+ assert_reset_authorized(settings=settings, options=options)
693
+ client = AsyncMongoClient[dict[str, object]](
694
+ settings.mongo_uri.get_secret_value(),
695
+ tz_aware=True,
696
+ uuidRepresentation="standard",
697
+ )
698
+ database = cast(MongoResetProtocol, client[settings.mongo_database])
699
+ redis_client = Redis.from_url(
700
+ settings.redis_uri.get_secret_value(),
701
+ decode_responses=True,
702
+ )
703
+ redis = cast(RedisResetProtocol, redis_client)
704
+ inspected = await inspect_state(
705
+ database=database,
706
+ redis=redis,
707
+ settings=settings,
708
+ options=options,
709
+ )
710
+ report = inspected
711
+ if options.mode is ResetMode.APPLY:
712
+ running_path = start_audit(options=options)
713
+ store = build_artifact_store(settings=settings)
714
+ await store.ensure_namespace_owner(
715
+ owner=settings.resolved_artifact_namespace_owner
716
+ )
717
+ report = await apply_reset(
718
+ database=database,
719
+ redis=redis,
720
+ store=store,
721
+ object_keys=iter_artifact_object_keys(database=database),
722
+ settings=settings,
723
+ inspected=inspected,
724
+ )
725
+ complete_audit(options=options, report=report, running_path=running_path)
726
+ await redis.aclose()
727
+ await client.close()
728
+ print(
729
+ dumps(
730
+ {
731
+ "environment": settings.environment.value,
732
+ "mongoDatabase": settings.mongo_database,
733
+ "redisNamespace": settings.redis_namespace,
734
+ "artifactOwner": settings.resolved_artifact_namespace_owner,
735
+ **_payload(options=options, report=report, status="SUCCEEDED"),
736
+ },
737
+ sort_keys=True,
738
+ )
739
+ )
740
+
741
+
742
+ if __name__ == "__main__":
743
+ run(amain())