feat(store): fetch contributions incrementally and persist history in SQLite
Bound each provider's startFetch()/resolveFetch() to an optional since/until window, wire a SQLite-backed ContributionStore into ContributionAggregator (fetch only the trailing window past the last stored date, merge into the store, prune by CONTRIBUTIONS_RETENTION_DAYS), and add a graph:contributions:refetch command to force a full or ranged re-fetch in <=365-day chunks. This is the root fix for the full 365-day-refetch timeout that used to hit on every cache miss.
This commit is contained in:
@@ -10,25 +10,64 @@ use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
final class ContributionAggregator
|
||||
{
|
||||
private const RENDER_WINDOW_DAYS = 371;
|
||||
private const OVERLAP_DAYS = 3;
|
||||
|
||||
/** @param iterable<ProviderInterface> $providers */
|
||||
public function __construct(
|
||||
#[AutowireIterator('app.provider')]
|
||||
private readonly iterable $providers,
|
||||
private readonly ContributionStore $store,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/** @return array<string, int> */
|
||||
/**
|
||||
* Incrementally fetches fresh contributions from each configured provider, merges them into
|
||||
* the store, and returns the rolling render window summed across providers.
|
||||
*
|
||||
* For each provider, only data since its last stored date (minus a trailing overlap) is
|
||||
* fetched; a provider with no stored history yet fetches its full default range. Fetches are
|
||||
* split into start/resolve phases so all providers' requests are in flight concurrently.
|
||||
* Failures are logged and the affected provider is skipped, not fatal to the others.
|
||||
*
|
||||
* @return array<string, int> date (Y-m-d) => contribution count, summed across all providers
|
||||
*/
|
||||
public function aggregate(): array
|
||||
{
|
||||
$pending = [];
|
||||
$configured = [];
|
||||
|
||||
/** @var ProviderInterface $provider */
|
||||
foreach ($this->providers as $provider) {
|
||||
if (!$provider->isConfigured()) {
|
||||
continue;
|
||||
if ($provider->isConfigured()) {
|
||||
$configured[] = $provider;
|
||||
}
|
||||
}
|
||||
|
||||
$pending = [];
|
||||
|
||||
foreach ($configured as $provider) {
|
||||
$latest = $this->store->latestDate($provider->getName());
|
||||
$since = $latest !== null
|
||||
? (new \DateTimeImmutable('@' . $latest))->modify('-' . self::OVERLAP_DAYS . ' days')
|
||||
: null;
|
||||
|
||||
try {
|
||||
$pending[] = [$provider, $provider->startFetch()];
|
||||
$pending[] = [$provider, $provider->startFetch($since)];
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pending as [$provider, $handle]) {
|
||||
try {
|
||||
$fresh = $provider->resolveFetch($handle);
|
||||
|
||||
$dateCounts = [];
|
||||
foreach ($fresh as $date => $count) {
|
||||
$dateCounts[(new \DateTimeImmutable($date))->getTimestamp()] = $count;
|
||||
}
|
||||
|
||||
$this->store->merge($provider->getName(), $dateCounts);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
|
||||
}
|
||||
@@ -36,16 +75,15 @@ final class ContributionAggregator
|
||||
|
||||
$contributions = [];
|
||||
|
||||
foreach ($pending as [$provider, $handle]) {
|
||||
try {
|
||||
foreach ($provider->resolveFetch($handle) as $date => $count) {
|
||||
$contributions[$date] = ($contributions[$date] ?? 0) + $count;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning(sprintf('%s fetch failed: %s', $provider::class, $e->getMessage()), ['exception' => $e]);
|
||||
foreach ($configured as $provider) {
|
||||
foreach ($this->store->all($provider->getName(), sinceDays: self::RENDER_WINDOW_DAYS) as $contribution) {
|
||||
$date = (new \DateTimeImmutable('@' . $contribution->date))->format('Y-m-d');
|
||||
$contributions[$date] = ($contributions[$date] ?? 0) + $contribution->count;
|
||||
}
|
||||
}
|
||||
|
||||
$this->store->prune();
|
||||
|
||||
return $contributions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,13 @@ final class GitHubProvider implements ProviderInterface
|
||||
])->getContent();
|
||||
}
|
||||
|
||||
public function startFetch(): ResponseInterface
|
||||
/** Fires the GraphQL contributionsCollection query bounded by $since/$until. */
|
||||
public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): ResponseInterface
|
||||
{
|
||||
$this->logger->debug('GitHubProvider: fetching contributions', ['user' => $this->username]);
|
||||
|
||||
$from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
|
||||
$to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
|
||||
$from = ($since ?? new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
|
||||
$to = ($until ?? new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
|
||||
|
||||
$query = sprintf(
|
||||
'query { user(login: %s) { contributionsCollection(from: %s, to: %s) { contributionCalendar { weeks { contributionDays { date contributionCount } } } } } }',
|
||||
@@ -71,7 +72,10 @@ final class GitHubProvider implements ProviderInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int> date (Y-m-d) => contribution count
|
||||
* Parses the GraphQL response into a per-day count map, skipping zero-count days.
|
||||
*
|
||||
* @param ResponseInterface $handle the response returned by startFetch()
|
||||
* @return array<string, int> date (Y-m-d) => contribution count
|
||||
*/
|
||||
public function resolveFetch(mixed $handle): array
|
||||
{
|
||||
|
||||
@@ -50,16 +50,18 @@ final class GitLabProvider implements ProviderInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{baseUrl: string, userId: int, after: string, page: int, response: ResponseInterface}
|
||||
* Resolves the username to a numeric user ID, then fires the first page of the events request.
|
||||
*
|
||||
* @return array{baseUrl: string, userId: int, after: string, before: ?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 startFetch(): array
|
||||
public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array
|
||||
{
|
||||
|
||||
|
||||
$this->logger->debug('GitLabProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]);
|
||||
|
||||
$userResponse = $this->client->request('GET', "$this->baseUrl/api/v4/users", [
|
||||
@@ -72,26 +74,31 @@ final class GitLabProvider implements ProviderInterface
|
||||
throw new NotFoundHttpException("GitLab: user '{$this->username}' not found on $this->baseUrl");
|
||||
}
|
||||
$userId = $users[0]['id'];
|
||||
$after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d');
|
||||
$after = ($since ?? new \DateTimeImmutable('-365 days'))->format('Y-m-d');
|
||||
$before = $until?->format('Y-m-d');
|
||||
|
||||
$query = ['after' => $after, 'per_page' => 100, 'page' => 1];
|
||||
if ($before !== null) {
|
||||
$query['before'] = $before;
|
||||
}
|
||||
|
||||
$response = $this->client->request('GET', "$this->baseUrl/api/v4/users/$userId/events", [
|
||||
'headers' => ['PRIVATE-TOKEN' => $this->token],
|
||||
'query' => [
|
||||
'after' => $after,
|
||||
'per_page' => 100,
|
||||
'page' => 1,
|
||||
],
|
||||
'query' => $query,
|
||||
]);
|
||||
|
||||
return ['baseUrl' => $this->baseUrl, 'userId' => $userId, 'after' => $after, 'page' => 1, 'response' => $response];
|
||||
return ['baseUrl' => $this->baseUrl, 'userId' => $userId, 'after' => $after, 'before' => $before, 'page' => 1, 'response' => $response];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int> date (Y-m-d) => event count
|
||||
* Follows pagination (100 events/page) until a short page is returned, counting events per day.
|
||||
*
|
||||
* @param array{baseUrl: string, userId: int, after: string, before: ?string, page: int, response: ResponseInterface} $handle
|
||||
* @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;
|
||||
['baseUrl' => $baseUrl, 'userId' => $userId, 'after' => $after, 'before' => $before, 'page' => $page, 'response' => $response] = $handle;
|
||||
|
||||
$result = [];
|
||||
|
||||
@@ -106,13 +113,14 @@ final class GitLabProvider implements ProviderInterface
|
||||
$page++;
|
||||
|
||||
if (count($events) === 100) {
|
||||
$query = ['after' => $after, 'per_page' => 100, 'page' => $page];
|
||||
if ($before !== null) {
|
||||
$query['before'] = $before;
|
||||
}
|
||||
|
||||
$response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
|
||||
'headers' => ['PRIVATE-TOKEN' => $this->token],
|
||||
'query' => [
|
||||
'after' => $after,
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
],
|
||||
'query' => $query,
|
||||
]);
|
||||
}
|
||||
} while (count($events) === 100);
|
||||
|
||||
@@ -51,31 +51,45 @@ final class GiteaProvider implements ProviderInterface
|
||||
])->getContent();
|
||||
}
|
||||
|
||||
public function startFetch(): ResponseInterface
|
||||
/**
|
||||
* Fires a single heatmap request; $since/$until are carried through unused for client-side filtering in resolveFetch().
|
||||
*
|
||||
* @return array{response: ResponseInterface, since: ?\DateTimeImmutable, until: ?\DateTimeImmutable}
|
||||
*/
|
||||
public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): array
|
||||
{
|
||||
|
||||
|
||||
$this->logger->debug('GiteaProvider: fetching contributions', ['user' => $this->username, 'url' => $this->baseUrl]);
|
||||
|
||||
return $this->client->request('GET', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [
|
||||
$response = $this->client->request('GET', "$this->baseUrl/api/v1/users/{$this->username}/heatmap", [
|
||||
'headers' => ['Authorization' => "token {$this->token}"],
|
||||
]);
|
||||
|
||||
return ['response' => $response, 'since' => $since, 'until' => $until];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int> date (Y-m-d) => contribution count
|
||||
* Filters the heatmap entries to the [$since, $until] window and sums contributions per day.
|
||||
*
|
||||
* @param array{response: ResponseInterface, since: ?\DateTimeImmutable, until: ?\DateTimeImmutable} $handle
|
||||
* @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();
|
||||
$result = [];
|
||||
['response' => $response, 'since' => $since, 'until' => $until] = $handle;
|
||||
|
||||
/** @var ResponseInterface $response */
|
||||
$data = $response->toArray();
|
||||
$cutoff = ($since ?? new \DateTimeImmutable('-365 days'))->getTimestamp();
|
||||
$ceiling = $until?->getTimestamp();
|
||||
$result = [];
|
||||
|
||||
foreach ($data as $entry) {
|
||||
if ($entry['timestamp'] < $cutoff) {
|
||||
continue;
|
||||
}
|
||||
if ($ceiling !== null && $entry['timestamp'] > $ceiling) {
|
||||
continue;
|
||||
}
|
||||
$date = date('Y-m-d', $entry['timestamp']);
|
||||
$result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions'];
|
||||
}
|
||||
|
||||
@@ -6,17 +6,33 @@ namespace GitContributionGraph\Service\Provider;
|
||||
|
||||
interface ProviderInterface
|
||||
{
|
||||
/** Fire the HTTP request(s) without blocking; returns an opaque handle for resolveFetch(). */
|
||||
public function startFetch(): mixed;
|
||||
/**
|
||||
* Fire the HTTP request(s) without blocking; the result is read later by
|
||||
* resolveFetch() so multiple providers' requests can be in flight at once.
|
||||
*
|
||||
* @param ?\DateTimeImmutable $since lower bound (inclusive); defaults to 365 days ago
|
||||
* @param ?\DateTimeImmutable $until upper bound (inclusive); defaults to now
|
||||
* @return mixed opaque handle to pass into resolveFetch()
|
||||
*/
|
||||
public function startFetch(?\DateTimeImmutable $since = null, ?\DateTimeImmutable $until = null): mixed;
|
||||
|
||||
/** @return array<string, int> date (Y-m-d) => contribution count */
|
||||
/**
|
||||
* Block on the handle from startFetch() and parse it into a per-day count map.
|
||||
*
|
||||
* @param mixed $handle the value returned by startFetch()
|
||||
* @return array<string, int> date (Y-m-d) => contribution count
|
||||
*/
|
||||
public function resolveFetch(mixed $handle): array;
|
||||
|
||||
/** Whether the required credentials/URL for this provider are all set. */
|
||||
public function isConfigured(): bool;
|
||||
|
||||
/** Short lowercase identifier for this provider, e.g. "github". */
|
||||
public function getName(): string;
|
||||
|
||||
/** Check reachability/credentials without fetching contribution data; used by the /health endpoint. */
|
||||
public function probe(): ProviderStatus;
|
||||
|
||||
/** Make a lightweight authenticated request to verify the provider is reachable; throws on failure. */
|
||||
public function ping(): void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user