clock->now(); $expiredIds = $this->manualBlocks->findExpired($now); if ($expiredIds === []) { return new JobResult(itemsProcessed: 0); } // Snapshot labels before the rows disappear so the audit row carries // the IP/CIDR text rather than just an opaque numeric id. $labels = []; foreach ($expiredIds as $id) { $row = $this->manualBlocks->findById($id); $labels[$id] = $row === null ? null : ($row->kind === ManualBlock::KIND_IP ? $row->ip?->text() : $row->cidr?->text()); } $deleted = $this->manualBlocks->deleteExpired($now); $auditCtx = AuditContext::system(); foreach ($expiredIds as $id) { $this->audit->emit( AuditAction::MANUAL_BLOCK_DELETED, 'manual_block', (string) $id, ['reason' => 'expired', 'job' => self::NAME, 'target' => $labels[$id] ?? null], $auditCtx, $labels[$id] ?? null, ); } // Heavy-handed but correct: any in-process snapshot is invalidated // so subsequent reads on this replica don't keep an expired row in // their cached view. $this->evaluator->invalidate(); $this->blocklistCache->invalidateAll(); return new JobResult( itemsProcessed: $deleted, details: ['expired_ids' => $expiredIds], ); } }