13 Commits
Author SHA1 Message Date
haylan cd326a34ba chore(controller): lower set_time_limit now that fetching is concurrent
Providers fetch in parallel now (cost ~= max, not sum), so the old
90s stopgap for the 3-sequential-providers-plus-pagination worst case
can come back down toward the 30s default, with headroom for
pagination and per-request HTTP timeouts.
2026-07-07 13:32:17 +02:00
haylan 8fb7781227 test(aggregator): cover concurrent fetch failure modes
Mocks startFetch()/resolveFetch() instead of fetch(), and splits the
old single failure test into separate start-throws and
resolve-throws cases since those are now distinct call sites.
2026-07-07 13:32:11 +02:00
haylan 1e2f022579 test(gitlab): exercise startFetch()/resolveFetch() instead of fetch() 2026-07-07 13:32:05 +02:00
haylan c89b4e9212 test(gitea): exercise startFetch()/resolveFetch() instead of fetch() 2026-07-07 13:31:59 +02:00
haylan 5c47888a49 test(github): exercise startFetch()/resolveFetch() instead of fetch() 2026-07-07 13:31:52 +02:00
haylan f2bf24994c test(provider): update ProbeTraitTest stub for split fetch API
The anonymous ProviderInterface implementation needs startFetch()/
resolveFetch() instead of fetch() to stay loadable.
2026-07-07 13:31:44 +02:00
haylan fcf841f7ad feat(aggregator): fetch all providers concurrently
aggregate() now fires every configured provider's startFetch() in one
pass before resolving any of them, so wall-clock cost is roughly
max(providers) instead of sum(providers). Partial-failure isolation
and per-date summation behavior are unchanged.
2026-07-07 13:31:36 +02:00
haylan 9b631f4777 refactor(gitlab): make GitLabProvider fetch non-blocking
startFetch() resolves the user id and fires the first events page,
returning a handle without reading it; resolveFetch() continues the
existing do/while pagination starting from that first response.

Pagination itself stays sequential within this provider since each
page depends on the previous one - parallelism here only spans across
providers, not within GitLab's own pages.
2026-07-07 13:31:30 +02:00
haylan 596260bb85 refactor(gitea): make GiteaProvider fetch non-blocking
startFetch() fires the heatmap GET and returns the response without
reading it; resolveFetch() does the .toArray()/parse/log work.
2026-07-07 13:31:21 +02:00
haylan 9f3c54afd1 refactor(github): make GitHubProvider fetch non-blocking
startFetch() fires the GraphQL POST and returns the response without
reading it; resolveFetch() does the .toArray()/parse/log work that
used to happen inline right after the request.
2026-07-07 13:31:15 +02:00
haylan 11bec43bbe refactor(provider): split fetch() into startFetch()/resolveFetch()
Splits the provider contract into a non-blocking startFetch() that
fires the HTTP request(s) and a resolveFetch() that reads the result,
so callers can fire every provider's request before blocking on any
of them. Symfony HttpClient is already async under the hood -
request() doesn't block until you read the response - so this needs
no new dependency.
2026-07-07 13:31:09 +02:00
haylan 09c0be8312 config: cap http_client timeout and max_duration
Per-request bounds so a single slow/hung provider can't stall the
whole request indefinitely once fetching happens concurrently.
2026-07-07 13:31:03 +02:00
haylan 6d6680ca37 fix(di): correct provider service FQCNs in services.yaml
Providers were moved into the App\Service\Provider namespace, but
services.yaml still tagged/configured them under the old App\Service
FQCNs, so the _instanceof conditional never matched and the container
failed to compile.
2026-07-07 13:30:57 +02:00
13 changed files with 149 additions and 51 deletions
+4
View File
@@ -7,3 +7,7 @@ framework:
cache: cache:
app: cache.adapter.filesystem app: cache.adapter.filesystem
default_redis_provider: 'redis://localhost' default_redis_provider: 'redis://localhost'
http_client:
default_options:
timeout: 10 # connect + wait-for-first-byte cap per request
max_duration: 15 # hard cap on total request duration
+4 -4
View File
@@ -22,21 +22,21 @@ services:
- '../src/Kernel.php' - '../src/Kernel.php'
_instanceof: _instanceof:
App\Service\ProviderInterface: App\Service\Provider\ProviderInterface:
tags: ['app.provider'] tags: ['app.provider']
App\Service\GitHubProvider: App\Service\Provider\GitHubProvider:
arguments: arguments:
$username: '%env(GITHUB_USER)%' $username: '%env(GITHUB_USER)%'
$token: '%env(GITHUB_TOKEN)%' $token: '%env(GITHUB_TOKEN)%'
App\Service\GitLabProvider: App\Service\Provider\GitLabProvider:
arguments: arguments:
$username: '%env(GITLAB_USER)%' $username: '%env(GITLAB_USER)%'
$token: '%env(GITLAB_TOKEN)%' $token: '%env(GITLAB_TOKEN)%'
$baseUrl: '%env(GITLAB_URL)%' $baseUrl: '%env(GITLAB_URL)%'
App\Service\GiteaProvider: App\Service\Provider\GiteaProvider:
arguments: arguments:
$username: '%env(GITEA_USER)%' $username: '%env(GITEA_USER)%'
$token: '%env(GITEA_TOKEN)%' $token: '%env(GITEA_TOKEN)%'
+1
View File
@@ -53,6 +53,7 @@ final class GraphController
$svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string { $svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($theme, &$cacheMiss): string {
$cacheMiss = true; $cacheMiss = true;
$item->expiresAfter(3600); $item->expiresAfter(3600);
set_time_limit(30); // ponytail: providers fetch concurrently now (cost ~= max, not sum); GitLab pagination + per-request HTTP timeout (framework.yaml) still need headroom over the 30s default
return $this->renderer->render($this->aggregator->aggregate(), $theme); return $this->renderer->render($this->aggregator->aggregate(), $theme);
}); });
+12 -2
View File
@@ -19,7 +19,7 @@ final class ContributionAggregator
/** @return array<string, int> */ /** @return array<string, int> */
public function aggregate(): array public function aggregate(): array
{ {
$contributions = []; $pending = [];
/** @var ProviderInterface $provider */ /** @var ProviderInterface $provider */
foreach ($this->providers as $provider) { foreach ($this->providers as $provider) {
@@ -28,7 +28,17 @@ final class ContributionAggregator
} }
try { try {
foreach ($provider->fetch() as $date => $count) { $pending[] = [$provider, $provider->startFetch()];
} catch (\Throwable $e) {
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
}
}
$contributions = [];
foreach ($pending as [$provider, $handle]) {
try {
foreach ($provider->resolveFetch($handle) as $date => $count) {
$contributions[$date] = ($contributions[$date] ?? 0) + $count; $contributions[$date] = ($contributions[$date] ?? 0) + $count;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
+13 -6
View File
@@ -9,6 +9,7 @@ use IDCI\Bundle\GraphQLClientBundle\Client\GraphQLApiClientRegistryInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/** /**
* Fetches the last 365 days of contributions from the GitHub GraphQL API. * Fetches the last 365 days of contributions from the GitHub GraphQL API.
@@ -46,10 +47,7 @@ final class GitHubProvider implements ProviderInterface
])->getContent(); ])->getContent();
} }
/** public function startFetch(): ResponseInterface
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function fetch(): array
{ {
$this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]); $this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]);
@@ -75,15 +73,24 @@ final class GitHubProvider implements ProviderInterface
// GitHub's GraphQL API requires application/json — the bundle's built-in // GitHub's GraphQL API requires application/json — the bundle's built-in
// transport sends form_params, so we use Symfony HttpClient here instead. // transport sends form_params, so we use Symfony HttpClient here instead.
$response = $this->client->request('POST', self::GRAPHQL_URL, [ // request() doesn't block; the response is read in resolveFetch() so
// multiple providers' requests can be in flight at once.
return $this->client->request('POST', self::GRAPHQL_URL, [
'headers' => [ 'headers' => [
'Authorization' => "Bearer {$this->token}", 'Authorization' => "Bearer {$this->token}",
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
], ],
'json' => ['query' => $query], 'json' => ['query' => $query],
]); ]);
}
$data = $response->toArray(); /**
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function resolveFetch(mixed $handle): array
{
/** @var ResponseInterface $handle */
$data = $handle->toArray();
if (isset($data['errors'])) { if (isset($data['errors'])) {
throw new ServiceUnavailableHttpException(null, 'GitHub GraphQL error: ' . json_encode($data['errors'])); throw new ServiceUnavailableHttpException(null, 'GitHub GraphQL error: ' . json_encode($data['errors']));
+39 -13
View File
@@ -7,6 +7,7 @@ namespace App\Service\Provider;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/** /**
* Fetches the last 365 days of push/merge events from the GitLab REST API. * Fetches the last 365 days of push/merge events from the GitLab REST API.
@@ -46,9 +47,14 @@ final class GitLabProvider implements ProviderInterface
} }
/** /**
* @return array<string, int> date (Y-m-d) => event count * @return array{baseUrl: string, userId: int, after: string, page: int, response: ResponseInterface}
*
* ponytail: the user-id lookup and event pagination stay sequential within
* this one provider (each page depends on the previous). Parallelism here
* only spans across providers; revisit only if GitLab pagination itself
* becomes the bottleneck.
*/ */
public function fetch(): array public function startFetch(): array
{ {
$baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/'); $baseUrl = rtrim($this->baseUrl !== '' ? $this->baseUrl : 'https://gitlab.com', '/');
@@ -64,21 +70,30 @@ final class GitLabProvider implements ProviderInterface
throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $baseUrl"); throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $baseUrl");
} }
$userId = $users[0]['id']; $userId = $users[0]['id'];
$after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d');
$response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => [
'after' => $after,
'per_page' => 100,
'page' => 1,
],
]);
return ['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'page' => 1, 'response' => $response];
}
/**
* @return array<string, int> date (Y-m-d) => event count
*/
public function resolveFetch(mixed $handle): array
{
['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'page' => $page, 'response' => $response] = $handle;
$result = []; $result = [];
$after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d');
$page = 1;
do { do {
$response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => [
'after' => $after,
'per_page' => 100,
'page' => $page,
],
]);
$events = $response->toArray(); $events = $response->toArray();
foreach ($events as $event) { foreach ($events as $event) {
@@ -87,6 +102,17 @@ final class GitLabProvider implements ProviderInterface
} }
$page++; $page++;
if (count($events) === 100) {
$response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
'headers' => ['PRIVATE-TOKEN' => $this->token],
'query' => [
'after' => $after,
'per_page' => 100,
'page' => $page,
],
]);
}
} while (count($events) === 100); } while (count($events) === 100);
$this->logger->info('GitLabProvider: fetched contributions', [ $this->logger->info('GitLabProvider: fetched contributions', [
+11 -6
View File
@@ -6,6 +6,7 @@ namespace App\Service\Provider;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/** /**
* Fetches contribution data from the Gitea heatmap endpoint. * Fetches contribution data from the Gitea heatmap endpoint.
@@ -46,20 +47,24 @@ final class GiteaProvider implements ProviderInterface
])->getContent(); ])->getContent();
} }
/** public function startFetch(): ResponseInterface
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function fetch(): array
{ {
$baseUrl = rtrim($this->baseUrl, '/'); $baseUrl = rtrim($this->baseUrl, '/');
$this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]); $this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $baseUrl]);
$response = $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [ return $this->client->request('GET', "$baseUrl/api/v1/users/{$this->username}/heatmap", [
'headers' => ['Authorization' => "token {$this->token}"], 'headers' => ['Authorization' => "token {$this->token}"],
]); ]);
}
$data = $response->toArray(); /**
* @return array<string, int> date (Y-m-d) => contribution count
*/
public function resolveFetch(mixed $handle): array
{
/** @var ResponseInterface $handle */
$data = $handle->toArray();
$cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp(); $cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp();
$result = []; $result = [];
+4 -1
View File
@@ -6,8 +6,11 @@ namespace App\Service\Provider;
interface ProviderInterface interface ProviderInterface
{ {
/** Fire the HTTP request(s) without blocking; returns an opaque handle for resolveFetch(). */
public function startFetch(): mixed;
/** @return array<string, int> date (Y-m-d) => contribution count */ /** @return array<string, int> date (Y-m-d) => contribution count */
public function fetch(): array; public function resolveFetch(mixed $handle): array;
public function isConfigured(): bool; public function isConfigured(): bool;
@@ -49,7 +49,7 @@ final class ContributionAggregatorTest extends TestCase
{ {
$provider = $this->createStub(ProviderInterface::class); $provider = $this->createStub(ProviderInterface::class);
$provider->method('isConfigured')->willReturn(true); $provider->method('isConfigured')->willReturn(true);
$provider->method('fetch')->willReturn(['2024-01-01' => 3]); $provider->method('resolveFetch')->willReturn(['2024-01-01' => 3]);
$aggregator = new ContributionAggregator([$provider], $this->logger); $aggregator = new ContributionAggregator([$provider], $this->logger);
@@ -63,11 +63,11 @@ final class ContributionAggregatorTest extends TestCase
{ {
$providerA = $this->createStub(ProviderInterface::class); $providerA = $this->createStub(ProviderInterface::class);
$providerA->method('isConfigured')->willReturn(true); $providerA->method('isConfigured')->willReturn(true);
$providerA->method('fetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]); $providerA->method('resolveFetch')->willReturn(['2024-01-01' => 3, '2024-01-02' => 1]);
$providerB = $this->createStub(ProviderInterface::class); $providerB = $this->createStub(ProviderInterface::class);
$providerB->method('isConfigured')->willReturn(true); $providerB->method('isConfigured')->willReturn(true);
$providerB->method('fetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]); $providerB->method('resolveFetch')->willReturn(['2024-01-01' => 2, '2024-01-03' => 5]);
$aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger); $aggregator = new ContributionAggregator([$providerA, $providerB], $this->logger);
@@ -77,15 +77,15 @@ final class ContributionAggregatorTest extends TestCase
} }
#[Test] #[Test]
public function it_continues_fetching_remaining_providers_when_one_throws(): void public function it_continues_fetching_remaining_providers_when_one_throws_on_start(): void
{ {
$failing = $this->createStub(ProviderInterface::class); $failing = $this->createStub(ProviderInterface::class);
$failing->method('isConfigured')->willReturn(true); $failing->method('isConfigured')->willReturn(true);
$failing->method('fetch')->willThrowException(new \RuntimeException('Network error')); $failing->method('startFetch')->willThrowException(new \RuntimeException('Network error'));
$healthy = $this->createStub(ProviderInterface::class); $healthy = $this->createStub(ProviderInterface::class);
$healthy->method('isConfigured')->willReturn(true); $healthy->method('isConfigured')->willReturn(true);
$healthy->method('fetch')->willReturn(['2024-01-01' => 7]); $healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger); $aggregator = new ContributionAggregator([$failing, $healthy], $this->logger);
@@ -95,14 +95,45 @@ final class ContributionAggregatorTest extends TestCase
} }
#[Test] #[Test]
public function it_logs_a_warning_when_a_provider_fetch_throws(): void public function it_continues_fetching_remaining_providers_when_one_throws_on_resolve(): void
{
$failing = $this->createStub(ProviderInterface::class);
$failing->method('isConfigured')->willReturn(true);
$failing->method('resolveFetch')->willThrowException(new \RuntimeException('Network error'));
$healthy = $this->createStub(ProviderInterface::class);
$healthy->method('isConfigured')->willReturn(true);
$healthy->method('resolveFetch')->willReturn(['2024-01-01' => 7]);
$aggregator = new ContributionAggregator([$failing, $healthy], $this->logger);
$result = $aggregator->aggregate();
$this->assertSame(['2024-01-01' => 7], $result);
}
#[Test]
public function it_logs_a_warning_when_a_provider_start_fetch_throws(): void
{ {
$logger = $this->createMock(LoggerInterface::class); $logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('warning'); $logger->expects($this->once())->method('warning');
$provider = $this->createStub(ProviderInterface::class); $provider = $this->createStub(ProviderInterface::class);
$provider->method('isConfigured')->willReturn(true); $provider->method('isConfigured')->willReturn(true);
$provider->method('fetch')->willThrowException(new \RuntimeException('fail')); $provider->method('startFetch')->willThrowException(new \RuntimeException('fail'));
(new ContributionAggregator([$provider], $logger))->aggregate();
}
#[Test]
public function it_logs_a_warning_when_a_provider_resolve_fetch_throws(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('warning');
$provider = $this->createStub(ProviderInterface::class);
$provider->method('isConfigured')->willReturn(true);
$provider->method('resolveFetch')->willThrowException(new \RuntimeException('fail'));
(new ContributionAggregator([$provider], $logger))->aggregate(); (new ContributionAggregator([$provider], $logger))->aggregate();
} }
@@ -96,7 +96,8 @@ final class GitHubProviderTest extends TestCase
]], ]],
])); ]));
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(4, $result['2024-06-10']); $this->assertSame(4, $result['2024-06-10']);
$this->assertSame(2, $result['2024-06-11']); $this->assertSame(2, $result['2024-06-11']);
@@ -112,7 +113,8 @@ final class GitHubProviderTest extends TestCase
]], ]],
])); ]));
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertArrayNotHasKey('2024-06-11', $result); $this->assertArrayNotHasKey('2024-06-11', $result);
} }
@@ -130,7 +132,8 @@ final class GitHubProviderTest extends TestCase
$this->expectException(ServiceUnavailableHttpException::class); $this->expectException(ServiceUnavailableHttpException::class);
$this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$provider->resolveFetch($provider->startFetch());
} }
#[Test] #[Test]
@@ -139,7 +142,8 @@ final class GitHubProviderTest extends TestCase
$client = $this->createStub(HttpClientInterface::class); $client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubGraphqlResponse([])); $client->method('request')->willReturn($this->stubGraphqlResponse([]));
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result); $this->assertSame([], $result);
} }
@@ -71,7 +71,8 @@ final class GitLabProviderTest extends TestCase
$this->expectException(NotFoundHttpException::class); $this->expectException(NotFoundHttpException::class);
$this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$provider->resolveFetch($provider->startFetch());
} }
#[Test] #[Test]
@@ -92,7 +93,8 @@ final class GitLabProviderTest extends TestCase
} }
); );
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(2, $result['2024-06-10']); $this->assertSame(2, $result['2024-06-10']);
$this->assertSame(1, $result['2024-06-11']); $this->assertSame(1, $result['2024-06-11']);
@@ -119,7 +121,8 @@ final class GitLabProviderTest extends TestCase
} }
); );
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(2, $callCount); $this->assertSame(2, $callCount);
$this->assertSame(100, $result['2024-06-10']); $this->assertSame(100, $result['2024-06-10']);
@@ -78,7 +78,8 @@ final class GiteaProviderTest extends TestCase
['timestamp' => $now, 'contributions' => 5], ['timestamp' => $now, 'contributions' => 5],
])); ]));
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame(5, $result[date('Y-m-d', $now)]); $this->assertSame(5, $result[date('Y-m-d', $now)]);
} }
@@ -93,7 +94,8 @@ final class GiteaProviderTest extends TestCase
['timestamp' => $old, 'contributions' => 3], ['timestamp' => $old, 'contributions' => 3],
])); ]));
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result); $this->assertSame([], $result);
} }
@@ -104,7 +106,8 @@ final class GiteaProviderTest extends TestCase
$client = $this->createStub(HttpClientInterface::class); $client = $this->createStub(HttpClientInterface::class);
$client->method('request')->willReturn($this->stubResponse([])); $client->method('request')->willReturn($this->stubResponse([]));
$result = $this->makeProvider(client: $client)->fetch(); $provider = $this->makeProvider(client: $client);
$result = $provider->resolveFetch($provider->startFetch());
$this->assertSame([], $result); $this->assertSame([], $result);
} }
@@ -31,7 +31,8 @@ final class ProbeTraitTest extends TestCase
public function isConfigured(): bool { return $this->configured; } public function isConfigured(): bool { return $this->configured; }
public function getName(): string { return 'test'; } public function getName(): string { return 'test'; }
public function ping(): void { ($this->ping)(); } public function ping(): void { ($this->ping)(); }
public function fetch(): array { return []; } public function startFetch(): mixed { return null; }
public function resolveFetch(mixed $handle): array { return []; }
}; };
} }