|
|
@@ -28,7 +28,7 @@ use App\Domain\Time\Clock;
|
|
|
class BlocklistCache
|
|
|
{
|
|
|
/**
|
|
|
- * @var array<int, array{blocklist: Blocklist, expires_at: float}>
|
|
|
+ * @var array<int, array{blocklist: Blocklist, expires_at: float, rendered: array<string, array{body: string, etag: string}>}>
|
|
|
*/
|
|
|
private array $cache = [];
|
|
|
|
|
|
@@ -52,12 +52,66 @@ class BlocklistCache
|
|
|
$this->cache[$policy->id] = [
|
|
|
'blocklist' => $blocklist,
|
|
|
'expires_at' => $now + $this->ttlSeconds,
|
|
|
+ 'rendered' => [],
|
|
|
];
|
|
|
}
|
|
|
|
|
|
return $blocklist;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * SEC_REVIEW F70: cache the rendered body and its sha256 ETag per
|
|
|
+ * (policy_id, format), not just the underlying `Blocklist` domain
|
|
|
+ * object. Without this, every cache hit rebuilt the rendered
|
|
|
+ * string and re-hashed it — at 50k entries per blocklist that's
|
|
|
+ * a multi-megabyte sha256 per request, sustained at the
|
|
|
+ * per-token rate limit (60 req/s). Now the render+hash runs
|
|
|
+ * once per (policy, format) per cache window; subsequent
|
|
|
+ * requests within the window reuse the cached `body` + `etag`
|
|
|
+ * verbatim and the per-request cost collapses to "send the
|
|
|
+ * bytes, return the headers".
|
|
|
+ *
|
|
|
+ * `$render` is invoked at most once per cache miss and receives
|
|
|
+ * the (possibly freshly-built) `Blocklist` object so the
|
|
|
+ * controller can keep its rendering logic colocated.
|
|
|
+ *
|
|
|
+ * @param callable(Blocklist): string $render
|
|
|
+ * @return array{body: string, etag: string, blocklist: Blocklist}
|
|
|
+ */
|
|
|
+ public function getRendered(Policy $policy, string $format, callable $render): array
|
|
|
+ {
|
|
|
+ $now = (float) $this->clock->now()->getTimestamp();
|
|
|
+ $entry = $this->cache[$policy->id] ?? null;
|
|
|
+ $valid = $entry !== null && $now < $entry['expires_at'];
|
|
|
+
|
|
|
+ if (!$valid) {
|
|
|
+ $blocklist = $this->builder->build($policy);
|
|
|
+ $entry = [
|
|
|
+ 'blocklist' => $blocklist,
|
|
|
+ 'expires_at' => $now + max(0, $this->ttlSeconds),
|
|
|
+ 'rendered' => [],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isset($entry['rendered'][$format])) {
|
|
|
+ $body = $render($entry['blocklist']);
|
|
|
+ $entry['rendered'][$format] = [
|
|
|
+ 'body' => $body,
|
|
|
+ 'etag' => '"' . hash('sha256', $body) . '"',
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($this->ttlSeconds > 0) {
|
|
|
+ $this->cache[$policy->id] = $entry;
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'body' => $entry['rendered'][$format]['body'],
|
|
|
+ 'etag' => $entry['rendered'][$format]['etag'],
|
|
|
+ 'blocklist' => $entry['blocklist'],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
public function invalidate(int $policyId): void
|
|
|
{
|
|
|
unset($this->cache[$policyId]);
|