CleanupExpiredManualBlocksJob.php 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Application\Jobs;
  4. use App\Domain\Audit\AuditAction;
  5. use App\Domain\Audit\AuditContext;
  6. use App\Domain\Audit\AuditEmitter;
  7. use App\Domain\Jobs\Job;
  8. use App\Domain\Jobs\JobContext;
  9. use App\Domain\Jobs\JobResult;
  10. use App\Domain\ManualBlock\ManualBlock;
  11. use App\Infrastructure\ManualBlock\ManualBlockRepository;
  12. use App\Infrastructure\Reputation\BlocklistCache;
  13. use App\Infrastructure\Reputation\CidrEvaluatorFactory;
  14. /**
  15. * Daily prune of expired `manual_blocks` rows.
  16. *
  17. * Read-time filtering (M14.5) already hides expired rows from every list,
  18. * lookup, and the CidrEvaluator's snapshot — so correctness doesn't depend
  19. * on this job. The job exists for tidiness: keeps the table from growing
  20. * unbounded with old expired entries.
  21. *
  22. * For each row deleted, emits one `manual_block.deleted` audit row with
  23. * `actor_kind=system` so the audit trail records who removed the block.
  24. * Invalidates the CidrEvaluator + BlocklistCache so any in-flight reads on
  25. * this replica observe the prune immediately.
  26. */
  27. final class CleanupExpiredManualBlocksJob implements Job
  28. {
  29. public const NAME = 'cleanup-expired-manual-blocks';
  30. public function __construct(
  31. private readonly ManualBlockRepository $manualBlocks,
  32. private readonly AuditEmitter $audit,
  33. private readonly CidrEvaluatorFactory $evaluator,
  34. private readonly BlocklistCache $blocklistCache,
  35. ) {
  36. }
  37. public function name(): string
  38. {
  39. return self::NAME;
  40. }
  41. public function defaultIntervalSeconds(): int
  42. {
  43. return 86400;
  44. }
  45. public function maxRuntimeSeconds(): int
  46. {
  47. return 60;
  48. }
  49. public function run(JobContext $context): JobResult
  50. {
  51. $now = $context->clock->now();
  52. $expiredIds = $this->manualBlocks->findExpired($now);
  53. if ($expiredIds === []) {
  54. return new JobResult(itemsProcessed: 0);
  55. }
  56. // Snapshot labels before the rows disappear so the audit row carries
  57. // the IP/CIDR text rather than just an opaque numeric id.
  58. $labels = [];
  59. foreach ($expiredIds as $id) {
  60. $row = $this->manualBlocks->findById($id);
  61. $labels[$id] = $row === null
  62. ? null
  63. : ($row->kind === ManualBlock::KIND_IP
  64. ? $row->ip?->text()
  65. : $row->cidr?->text());
  66. }
  67. $deleted = $this->manualBlocks->deleteExpired($now);
  68. $auditCtx = AuditContext::system();
  69. foreach ($expiredIds as $id) {
  70. $this->audit->emit(
  71. AuditAction::MANUAL_BLOCK_DELETED,
  72. 'manual_block',
  73. (string) $id,
  74. ['reason' => 'expired', 'job' => self::NAME, 'target' => $labels[$id] ?? null],
  75. $auditCtx,
  76. $labels[$id] ?? null,
  77. );
  78. }
  79. // Heavy-handed but correct: any in-process snapshot is invalidated
  80. // so subsequent reads on this replica don't keep an expired row in
  81. // their cached view.
  82. $this->evaluator->invalidate();
  83. $this->blocklistCache->invalidateAll();
  84. return new JobResult(
  85. itemsProcessed: $deleted,
  86. details: ['expired_ids' => $expiredIds],
  87. );
  88. }
  89. }