You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pixelfed/app/Services/DeliveryHostService.php

365 lines
9.8 KiB
PHP

<?php
namespace App\Services;
use App\Models\Instance;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Per-host ActivityPub delivery health.
*
* Fanout to a large follower audience always includes hosts that are dead,
* defunct or persistently unreachable. Rather than validating, signing and
* attempting delivery to those hosts on every activity, consecutive failures
* are counted on the host's `instances` row. Once the count reaches the
* configured threshold the host is marked unavailable with an exponential
* backoff and skipped until `delivery_next_after` has passed. A single
* reachable response (anything below HTTP 500, including 4xx) clears it.
*
* Config keys:
* federation.activitypub.delivery.failure_threshold default 5
* federation.activitypub.delivery.max_backoff seconds, default 7 days
*/
class DeliveryHostService
{
private const string CACHE_KEY = 'pf:services:delivery:hosts';
private const int CACHE_TTL = 300;
/*
* Queue workers are long-lived, so the cached map is also memoized in
* process for a short window. This keeps a 1,500-inbox fanout from
* hitting Redis once per inbox.
*/
private const int MEMO_TTL = 30;
private const int BASE_BACKOFF = 3600;
private const int MAX_BACKOFF_EXPONENT = 16;
/** @var array<string, int|null>|null */
private static ?array $memo = null;
private static int $memoAt = 0;
/**
* Lowercase host of an inbox URL, or null if it cannot be parsed.
*/
public static function domain(?string $url): ?string
{
if (! is_string($url) || trim($url) === '') {
return null;
}
$host = parse_url(trim($url), PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return null;
}
return strtolower($host);
}
public static function isUnavailable(string $domain): bool
{
$next = self::flagged()[strtolower($domain)] ?? null;
return $next !== null && $next > time();
}
/**
* Domains currently being skipped.
*
* @return array<int, string>
*/
public static function unavailable(): array
{
$now = time();
return array_keys(
array_filter(
self::flagged(),
fn (?int $next): bool => $next !== null && $next > $now
)
);
}
/**
* Drop inbox URLs whose host is unavailable, plus any null or empty
* entries. Keys are preserved so callers can map back to the original
* audience index.
*
* @param array<int, mixed> $inboxes
* @return array<int, string>
*/
public static function filter(array $inboxes): array
{
$kept = [];
foreach ($inboxes as $index => $inbox) {
if (! is_string($inbox) || trim($inbox) === '') {
continue;
}
$domain = self::domain($inbox);
if ($domain && self::isUnavailable($domain)) {
continue;
}
$kept[$index] = $inbox;
}
return $kept;
}
public static function recordFailure(string $domain): void
{
self::recordFailures([$domain]);
}
/**
* @param array<int, string> $domains
*/
public static function recordFailures(array $domains): void
{
$domains = self::normalize($domains);
if ($domains === []) {
return;
}
foreach ($domains as $domain) {
self::applyFailure($domain);
}
self::flush();
}
public static function recordSuccess(string $domain): void
{
self::recordSuccesses([$domain]);
}
/**
* Only hosts with an existing failure count are touched, so this costs
* nothing for the healthy majority of a fanout.
*
* @param array<int, string> $domains
*/
public static function recordSuccesses(array $domains): void
{
$flagged = self::flagged();
$domains = array_filter(
self::normalize($domains),
fn (string $domain): bool => array_key_exists($domain, $flagged)
);
if ($domains === []) {
return;
}
self::clear(array_values($domains));
}
/**
* Clear a host's failure state manually (admin / tinker).
*/
public static function reset(string $domain): void
{
$domains = self::normalize([$domain]);
if ($domains === []) {
return;
}
self::clear(array_values($domains));
}
/**
* @param array<int, string> $domains
*/
private static function clear(array $domains): void
{
try {
Instance::query()
->whereIn('domain', $domains)
->update([
'delivery_failures' => 0,
'delivery_timeout' => false,
'delivery_next_after' => null,
]);
} catch (Throwable $e) {
Log::warning('Unable to clear delivery failures', [
'domains' => $domains,
'exception' => $e::class,
'error' => $e->getMessage(),
]);
}
self::flush();
}
private static function applyFailure(string $domain): void
{
try {
$instance = Instance::whereDomain($domain)->first();
if (! $instance) {
/*
* Explicit assignment rather than firstOrCreate() so this
* does not depend on Instance::$fillable.
*/
$instance = new Instance;
$instance->domain = $domain;
$instance->save();
}
$instance->increment('delivery_failures');
$failures = (int) $instance->delivery_failures;
$threshold = self::threshold();
if ($failures < $threshold) {
return;
}
$instance->forceFill([
'delivery_timeout' => true,
'delivery_next_after' => Carbon::now()->addSeconds(
self::backoff($failures - $threshold)
),
])->save();
} catch (Throwable $e) {
Log::warning('Unable to record delivery failure', [
'domain' => $domain,
'exception' => $e::class,
'error' => $e->getMessage(),
]);
}
}
/**
* Domains with a non-zero failure count, mapped to the unix timestamp
* after which delivery may be retried, or null while still under the
* threshold.
*
* @return array<string, int|null>
*/
private static function flagged(): array
{
if (
self::$memo !== null
&& (time() - self::$memoAt) < self::MEMO_TTL
) {
return self::$memo;
}
self::$memoAt = time();
try {
return self::$memo = Cache::remember(
self::CACHE_KEY,
self::CACHE_TTL,
function () {
return Instance::query()
->where('delivery_failures', '>', 0)
->get([
'domain',
'delivery_timeout',
'delivery_next_after',
])
->mapWithKeys(function ($instance) {
$next = null;
if (
$instance->delivery_timeout
&& $instance->delivery_next_after
) {
$next = Carbon::parse(
$instance->delivery_next_after
)->getTimestamp();
}
return [strtolower($instance->domain) => $next];
})
->all();
}
);
} catch (Throwable $e) {
/*
* Never let host tracking break delivery. Memoize the empty
* result so a broken setup (e.g. migration not run) is logged
* once per window rather than once per inbox.
*/
Log::warning('Unable to load delivery host state', [
'exception' => $e::class,
'error' => $e->getMessage(),
]);
return self::$memo = [];
}
}
private static function flush(): void
{
self::$memo = null;
self::$memoAt = 0;
Cache::forget(self::CACHE_KEY);
}
/**
* @param array<int, mixed> $domains
* @return array<string, string> Deduplicated, keyed by domain
*/
private static function normalize(array $domains): array
{
$out = [];
foreach ($domains as $domain) {
if (! is_string($domain)) {
continue;
}
$domain = strtolower(trim($domain));
if ($domain === '') {
continue;
}
$out[$domain] = $domain;
}
return $out;
}
private static function threshold(): int
{
return max(
1,
(int) config('federation.activitypub.delivery.failure_threshold', 5)
);
}
/**
* Seconds to wait before retrying, doubling with each failure past the
* threshold: 1h, 2h, 4h ... capped at max_backoff.
*/
private static function backoff(int $excess): int
{
$max = max(
self::BASE_BACKOFF,
(int) config('federation.activitypub.delivery.max_backoff', 604800)
);
$exponent = min(max($excess, 0), self::MAX_BACKOFF_EXPONENT);
return (int) min(self::BASE_BACKOFF * (2 ** $exponent), $max);
}
}