options === null) { return; } $now = $this->clock->now(); if ($now - $this->lastSuccess($siteId, $connectionId) >= self::STAMP_INTERVAL) { $this->options->set(self::stampKey($siteId, $connectionId), $now); } } /** * $source is the caller's client bucket and $conversation its conversation bucket (see sourceOf()). Without them only the * connection-wide rules apply. * * @return array{reason:string, retry_after:int}|null null = the request may be dispatched */ public function check(int $siteId, int $connectionId, ?string $source = null, ?string $conversation = null): ?array { $now = $this->clock->now(); $running = $this->db->select( "SELECT request_id, buckets FROM {reservations} WHERE site_id = ? AND connection_id = ? AND state IN ('reserved', 'dispatched') AND updated_at >= ? LIMIT " . self::ROW_CAP, [$siteId, $connectionId, $now - self::STALE] ); $perSource = []; foreach ($running as $r) { $key = self::sourceOf((string) $r['buckets'], (string) $r['request_id']); $perSource[$key] = ($perSource[$key] ?? 0) + 1; } // 1. One client cannot hold more than its share of the connection: it is refused, the others are not. if ($source !== null && ($perSource[$source] ?? 0) > self::SOURCE_IN_FLIGHT) { return ['reason' => 'busy', 'retry_after' => 10]; } // 2. Shared capacity. No client counts for more than SOURCE_IN_FLIGHT. $effective = 0; foreach ($perSource as $n) { $effective += min($n, self::SOURCE_IN_FLIGHT); } if ($effective > self::MAX_IN_FLIGHT || count($running) >= self::ROW_CAP) { return ['reason' => 'busy', 'retry_after' => 10]; } [$clients, $conversations] = $this->failureGroups($siteId, $connectionId, $now); // 3. Repeated failures pause the conversation, or (more of them) the client, alone. These use ALL failures in the window: // a success elsewhere does not excuse a visitor who keeps failing. foreach ([[$conversation, $conversations, self::CONVERSATION_THRESHOLD], [$source, $clients, self::CLIENT_THRESHOLD]] as [$who, $groups, $limit]) { if ($who !== null && isset($groups[$who]) && $groups[$who]['n'] >= $limit && $now - $groups[$who]['last'] < self::COOLDOWN) { return ['reason' => 'source', 'retry_after' => max(1, self::COOLDOWN - ($now - $groups[$who]['last']))]; } } // 4. The shared breaker: distinct clients whose failure is newer than the connection's latest success. $ok = $this->lastSuccess($siteId, $connectionId); $count = 0; $last = 0; foreach ($clients as $c) { if ($c['last'] > $ok) { $count++; $last = max($last, $c['last']); } } if ($count < self::THRESHOLD) { return null; } $elapsed = $now - $last; if ($elapsed < self::COOLDOWN) { return ['reason' => 'open', 'retry_after' => max(1, self::COOLDOWN - $elapsed)]; } // Half-open: a single probe. The running list includes the caller's own row, so more than one means another request is running. return count($running) > 1 ? ['reason' => 'probing', 'retry_after' => 10] : null; } /** * Connections whose breaker is currently open or half-open, for the administrator notice. * * @return list */ public function tripped(int $siteId): array { $now = $this->clock->now(); $rows = $this->db->select( "SELECT connection_id, request_id, buckets, updated_at FROM {reservations} WHERE site_id = ? AND connection_id IS NOT NULL AND state = 'uncertain' AND note LIKE 'provider-%' AND note <> 'provider-cancelled' AND updated_at >= ? ORDER BY updated_at DESC LIMIT " . (self::ROW_CAP * 4), [$siteId, $now - self::WINDOW] ); $by = []; foreach ($rows as $r) { $c = (int) $r['connection_id']; $key = self::sourceOf((string) $r['buckets'], (string) $r['request_id']); $by[$c]['clients'][$key] = max($by[$c]['clients'][$key] ?? 0, (int) $r['updated_at']); $by[$c]['n'] = ($by[$c]['n'] ?? 0) + 1; } $out = []; foreach ($by as $connection => $v) { $ok = $this->lastSuccess($siteId, $connection); $fresh = array_filter($v['clients'], static fn(int $t): bool => $t > $ok); if (count($fresh) >= self::THRESHOLD) { $last = max($fresh); $out[] = ['connection_id' => $connection, 'failures' => $v['n'], 'last' => $last, 'open' => $now - $last < self::COOLDOWN, 'resumes_at' => $last + self::COOLDOWN]; } } return $out; } /** * Ambiguous failures in the window, grouped by client and by conversation. * * @return array{0:array, 1:array} */ private function failureGroups(int $siteId, int $connectionId, int $now): array { $rows = $this->db->select( "SELECT request_id, buckets, updated_at FROM {reservations} WHERE site_id = ? AND connection_id = ? AND state = 'uncertain' AND note LIKE 'provider-%' AND note <> 'provider-cancelled' AND updated_at >= ? ORDER BY updated_at DESC LIMIT " . self::ROW_CAP, [$siteId, $connectionId, $now - self::WINDOW] ); $clients = []; $conversations = []; foreach ($rows as $r) { $t = (int) $r['updated_at']; $key = self::sourceOf((string) $r['buckets'], (string) $r['request_id']); $clients[$key]['n'] = ($clients[$key]['n'] ?? 0) + 1; $clients[$key]['last'] = max($clients[$key]['last'] ?? 0, $t); $conv = self::conversationOf((string) $r['buckets']); if ($conv !== null) { $conversations[$conv]['n'] = ($conversations[$conv]['n'] ?? 0) + 1; $conversations[$conv]['last'] = max($conversations[$conv]['last'] ?? 0, $t); } } return [$clients, $conversations]; } /** When this connection last returned a usable answer (0 = never recorded). Throttled by recordSuccess, so it can be a few seconds old. */ public function lastSuccessAt(int $siteId, int $connectionId): int { return $this->lastSuccess($siteId, $connectionId); } private function lastSuccess(int $siteId, int $connectionId): int { return $this->options === null ? 0 : max(0, (int) $this->options->get(self::stampKey($siteId, $connectionId), 0)); } private static function stampKey(int $siteId, int $connectionId): string { return 'aiwend_breaker_ok_' . $siteId . '_' . $connectionId; } /** * The client a reservation is attributed to: its client bucket, else its conversation, else the request itself (older rows and rows * without either bucket count as one client each, so they can only make the breaker more cautious). */ public static function sourceOf(string $bucketsJson, string $requestId): string { $buckets = json_decode($bucketsJson, true); $session = null; if (is_array($buckets)) { foreach ($buckets as $b) { if (is_string($b) && str_starts_with($b, 'client:')) { return $b; } if ($session === null && is_string($b) && str_starts_with($b, 'session:')) { $session = $b; } } } return $session ?? 'request:' . $requestId; } private static function conversationOf(string $bucketsJson): ?string { $buckets = json_decode($bucketsJson, true); if (is_array($buckets)) { foreach ($buckets as $b) { if (is_string($b) && str_starts_with($b, 'session:')) { return $b; } } } return null; } }