BlocklistPerfTest.php 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Perf;
  4. use App\Domain\Ip\Cidr;
  5. use App\Domain\Reputation\BlocklistBuilder;
  6. use App\Infrastructure\Policy\PolicyRepository;
  7. use App\Tests\Integration\Support\AppTestCase;
  8. use Doctrine\DBAL\ParameterType;
  9. use PHPUnit\Framework\Attributes\Group;
  10. /**
  11. * SPEC §M07.5: 50k scored IPs build a blocklist in <500 ms.
  12. *
  13. * Skipped from the default test run via the `perf` group; run with
  14. * `composer test-perf`. Times the warm build (cache disabled in
  15. * AppTestCase) on SQLite. MySQL number is captured separately in CI.
  16. */
  17. #[Group('perf')]
  18. final class BlocklistPerfTest extends AppTestCase
  19. {
  20. public function test50kEntriesUnder500Ms(): void
  21. {
  22. $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
  23. $spamId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'spam']);
  24. $this->seedScores(50_000, [$bruteForceId, $spamId]);
  25. $this->seedManualSubnets(100);
  26. /** @var PolicyRepository $policies */
  27. $policies = $this->container->get(PolicyRepository::class);
  28. $paranoid = $policies->findByName('paranoid');
  29. self::assertNotNull($paranoid);
  30. /** @var BlocklistBuilder $builder */
  31. $builder = $this->container->get(BlocklistBuilder::class);
  32. $start = hrtime(true);
  33. $blocklist = $builder->build($paranoid);
  34. $elapsedMs = (hrtime(true) - $start) / 1_000_000.0;
  35. self::assertGreaterThan(0, $blocklist->count());
  36. // Second run to measure warm path (SPEC's <500ms is the warm budget).
  37. $start = hrtime(true);
  38. $blocklist = $builder->build($paranoid);
  39. $elapsedMs = (hrtime(true) - $start) / 1_000_000.0;
  40. self::assertLessThan(
  41. 500.0,
  42. $elapsedMs,
  43. sprintf('blocklist build took %.2f ms; budget 500 ms', $elapsedMs)
  44. );
  45. // Surface the measured number for the PROGRESS notes.
  46. fwrite(STDERR, sprintf("\n[perf] BlocklistBuilder@50k = %.1f ms (entries=%d)\n", $elapsedMs, $blocklist->count()));
  47. }
  48. /**
  49. * @param list<int> $categoryIds
  50. */
  51. private function seedScores(int $count, array $categoryIds): void
  52. {
  53. // Mix of v4 (75%) and v6 (25%) addresses, varied scores so that
  54. // some are above and some below the 0.3 paranoid threshold.
  55. $this->db->beginTransaction();
  56. $stmt = $this->db->prepare(
  57. 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
  58. . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
  59. );
  60. $now = (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s');
  61. for ($i = 0; $i < $count; ++$i) {
  62. $isV4 = ($i % 4) !== 0;
  63. if ($isV4) {
  64. $a = ($i >> 16) & 0xff;
  65. $b = ($i >> 8) & 0xff;
  66. $c = $i & 0xff;
  67. // Use 10.x.x.x to keep these well outside any allowlist concern in the test DB.
  68. $text = sprintf('10.%d.%d.%d', $a, $b, $c);
  69. } else {
  70. $text = sprintf('2001:db8::%x', $i);
  71. }
  72. $bin = $isV4
  73. ? "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff" . pack('C4', 10, ($i >> 16) & 0xff, ($i >> 8) & 0xff, $i & 0xff)
  74. : (string) inet_pton($text);
  75. $score = number_format(0.5 + (($i % 5) * 0.5), 4, '.', ''); // 0.5 .. 2.5
  76. $catId = $categoryIds[$i % count($categoryIds)];
  77. $stmt->bindValue('b', $bin, ParameterType::LARGE_OBJECT);
  78. $stmt->bindValue('t', $text);
  79. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  80. $stmt->bindValue('s', $score);
  81. $stmt->bindValue('now', $now);
  82. $stmt->executeStatement();
  83. }
  84. $this->db->commit();
  85. }
  86. private function seedManualSubnets(int $count): void
  87. {
  88. $this->db->beginTransaction();
  89. $stmt = $this->db->prepare(
  90. 'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
  91. );
  92. for ($i = 0; $i < $count; ++$i) {
  93. $cidr = Cidr::fromString(sprintf('192.0.%d.0/24', $i));
  94. $stmt->bindValue('kind', 'subnet');
  95. $stmt->bindValue('net', $cidr->network(), ParameterType::LARGE_OBJECT);
  96. $stmt->bindValue('pl', $cidr->prefixLength(), ParameterType::INTEGER);
  97. $stmt->bindValue('reason', 'perf');
  98. $stmt->executeStatement();
  99. }
  100. $this->db->commit();
  101. }
  102. }