Skip to content

Commit fa3fee9

Browse files
committed
Cache the dependent/suggester counts in Redis
getDependentCount() and getSuggestCount() run a COUNT(*) over the dependent/suggester tables on every package page view, again in the package JSON API, and again on the dependents/suggesters pages. For a widely-required package like psr/log that is a several-hundred-thousand-row index scan, and the package page alone accounts for 4.5M requests per APM period. The existing indexes already cover the queries, so there is nothing left to optimise in the query itself - the fix is to stop running it. Cache both counts for an hour with a random variance to spread out the refresh of the most-requested packages. A count badge being an hour out of date is harmless, so this is TTL-only: dependent rows are written keyed by the required package name while the writer operates on the requiring package, so precise invalidation would mean a key deletion per required name on every update. Keys are lowercased because the packageName columns use a case-insensitive collation and differently-cased requests must not get separate entries. The test env now points the cache client at its own Redis DB, and IntegrationTestCase flushes it per test: the DB is rolled back between tests but Redis is not, so counts keyed by package name would otherwise leak into later tests that reuse a name.
1 parent 0a01a73 commit fa3fee9

5 files changed

Lines changed: 112 additions & 18 deletions

File tree

.env.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ APP_SECRET='$ecretf0rt3st'
44
DATABASE_URL="mysql://root@127.0.0.1:3306/packagist?serverVersion=8.4.7"
55
MAILER_DSN=null://null
66
REDIS_URL=redis://localhost/14
7+
REDIS_CACHE_URL=redis://localhost/15
78
APP_MAILER_FROM_EMAIL=packagist@example.org
89
APP_HOSTNAME=packagist.wip
910
DEFAULT_URI=http://packagist.wip

phpstan-baseline.neon

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,6 @@ parameters:
5454
count: 1
5555
path: src/Entity/AuditRecordRepository.php
5656

57-
-
58-
message: '#^Method App\\Entity\\PackageRepository\:\:getSuggestCount\(\) should return int\<0, max\> but returns int\.$#'
59-
identifier: return.type
60-
count: 1
61-
path: src/Entity/PackageRepository.php
62-
6357
-
6458
message: '#^Query error\: Unknown column ''d\.total'' in ''order clause'' \(1054\)\.$#'
6559
identifier: dba.syntaxError

src/Entity/PackageRepository.php

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Doctrine\ORM\Query;
2121
use Doctrine\ORM\QueryBuilder;
2222
use Doctrine\Persistence\ManagerRegistry;
23+
use Predis\Client;
2324

2425
/**
2526
* @author Jordi Boggiano <j.boggiano@seld.be>
@@ -32,8 +33,10 @@ class PackageRepository extends ServiceEntityRepository
3233
// @phpstan-ignore classConstant.unused
3334
private const LISTING_WITH_AUTO_UPDATE_WARNINGS_FIELDS = 'id, name, description, type, gitHubStars, frozen, language, abandoned, replacementPackage, autoUpdated, repository';
3435

35-
public function __construct(ManagerRegistry $registry)
36-
{
36+
public function __construct(
37+
ManagerRegistry $registry,
38+
private Client $redisCache,
39+
) {
3740
parent::__construct($registry, Package::class);
3841
}
3942

@@ -627,14 +630,16 @@ public function getReadmeContentsByPackageIds(array $ids): array
627630
*/
628631
public function getDependentCount(string $name, ?int $type = null): int
629632
{
630-
$sql = 'SELECT COUNT(*) count FROM dependent WHERE packageName = :name';
631-
$args = ['name' => $name];
632-
if (null !== $type) {
633-
$sql .= ' AND type = :type';
634-
$args['type'] = $type;
635-
}
633+
return $this->getCachedCount('dep-count:'.strtolower($name).':'.($type ?? 'all'), function () use ($name, $type): int {
634+
$sql = 'SELECT COUNT(*) count FROM dependent WHERE packageName = :name';
635+
$args = ['name' => $name];
636+
if (null !== $type) {
637+
$sql .= ' AND type = :type';
638+
$args['type'] = $type;
639+
}
636640

637-
return max(0, (int) $this->getEntityManager()->getConnection()->fetchOne($sql, $args));
641+
return (int) $this->getEntityManager()->getConnection()->fetchOne($sql, $args);
642+
});
638643
}
639644

640645
/**
@@ -709,10 +714,37 @@ public function getDefaultBranchRequireFor(array $requirers, string $requiree):
709714
*/
710715
public function getSuggestCount(string $name): int
711716
{
712-
$sql = 'SELECT COUNT(*) count FROM suggester WHERE packageName = :name';
713-
$args = ['name' => $name];
717+
return $this->getCachedCount('sug-count:'.strtolower($name), function () use ($name): int {
718+
$sql = 'SELECT COUNT(*) count FROM suggester WHERE packageName = :name';
719+
720+
return (int) $this->getEntityManager()->getConnection()->fetchOne($sql, ['name' => $name]);
721+
});
722+
}
723+
724+
/**
725+
* Both counts are rendered as tab labels on every package page view, where the COUNT(*) is a
726+
* large index scan for widely-required packages like psr/log. Being an hour out of date on a
727+
* badge is harmless, so this is TTL-only with no explicit invalidation.
728+
*
729+
* Keys are lowercased because the packageName columns use a case-insensitive collation, so
730+
* differently-cased requests must not get separate entries.
731+
*
732+
* @param callable(): int $compute
733+
*
734+
* @return int<0, max>
735+
*/
736+
private function getCachedCount(string $cacheKey, callable $compute): int
737+
{
738+
$cached = $this->redisCache->get($cacheKey);
739+
if ($cached !== null) {
740+
return max(0, (int) $cached);
741+
}
742+
743+
$count = max(0, $compute());
744+
// random variance spreads out the refresh of the most-requested packages
745+
$this->redisCache->setex($cacheKey, 3600 + random_int(0, 600), (string) $count);
714746

715-
return (int) $this->getEntityManager()->getConnection()->fetchOne($sql, $args);
747+
return $count;
716748
}
717749

718750
/**

tests/Entity/PackageRepositoryTest.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212

1313
namespace App\Tests\Entity;
1414

15+
use App\Entity\Dependent;
1516
use App\Entity\Package;
1617
use App\Entity\PackageFreezeReason;
1718
use App\Entity\PackageRepository;
19+
use App\Entity\Suggester;
1820
use App\Tests\IntegrationTestCase;
21+
use Predis\Client;
1922

2023
class PackageRepositoryTest extends IntegrationTestCase
2124
{
@@ -197,4 +200,63 @@ public function testGetFilteredQueryBuilderExcludesSuppressedByDefault(): void
197200
self::assertContains('vendor/spam', $withFrozen);
198201
self::assertContains('vendor/malware', $withFrozen);
199202
}
203+
204+
public function testGetDependentCountIsCachedPerType(): void
205+
{
206+
$requirer = self::createPackage('test/requirer', 'https://example.org/requirer');
207+
$devRequirer = self::createPackage('test/dev-requirer', 'https://example.org/dev-requirer');
208+
$this->store($requirer, $devRequirer);
209+
$this->store(
210+
new Dependent($requirer, 'test/required', Dependent::TYPE_REQUIRE),
211+
new Dependent($devRequirer, 'test/required', Dependent::TYPE_REQUIRE_DEV),
212+
);
213+
214+
self::assertSame(2, $this->packageRepository->getDependentCount('test/required'));
215+
self::assertSame(1, $this->packageRepository->getDependentCount('test/required', Dependent::TYPE_REQUIRE));
216+
self::assertSame(1, $this->packageRepository->getDependentCount('test/required', Dependent::TYPE_REQUIRE_DEV));
217+
218+
// each variant gets its own key, so the type filter cannot be served from the unfiltered count
219+
self::assertSame('2', $this->redisCache()->get('dep-count:test/required:all'));
220+
self::assertSame('1', $this->redisCache()->get('dep-count:test/required:'.Dependent::TYPE_REQUIRE));
221+
self::assertSame('1', $this->redisCache()->get('dep-count:test/required:'.Dependent::TYPE_REQUIRE_DEV));
222+
}
223+
224+
public function testGetDependentCountReadsTheCacheAndIgnoresNameCasing(): void
225+
{
226+
$this->redisCache()->set('dep-count:test/required:all', '42');
227+
228+
self::assertSame(42, $this->packageRepository->getDependentCount('test/required'));
229+
// packageName uses a case-insensitive collation, so casing must not produce a second entry
230+
self::assertSame(42, $this->packageRepository->getDependentCount('Test/Required'));
231+
}
232+
233+
public function testGetSuggestCountIsCached(): void
234+
{
235+
$suggester = self::createPackage('test/suggester', 'https://example.org/suggester');
236+
$this->store($suggester);
237+
$this->store(new Suggester($suggester, 'test/suggested'));
238+
239+
self::assertSame(1, $this->packageRepository->getSuggestCount('test/suggested'));
240+
self::assertSame('1', $this->redisCache()->get('sug-count:test/suggested'));
241+
242+
$this->redisCache()->set('sug-count:test/suggested', '7');
243+
self::assertSame(7, $this->packageRepository->getSuggestCount('test/suggested'));
244+
}
245+
246+
public function testCountsCacheZeroSoUnknownPackagesDoNotRequeryEveryPageView(): void
247+
{
248+
self::assertSame(0, $this->packageRepository->getDependentCount('test/nothing-requires-this'));
249+
self::assertSame(0, $this->packageRepository->getSuggestCount('test/nothing-requires-this'));
250+
251+
self::assertSame('0', $this->redisCache()->get('dep-count:test/nothing-requires-this:all'));
252+
self::assertSame('0', $this->redisCache()->get('sug-count:test/nothing-requires-this'));
253+
}
254+
255+
private function redisCache(): Client
256+
{
257+
$client = static::getContainer()->get('snc_redis.cache');
258+
self::assertInstanceOf(Client::class, $client);
259+
260+
return $client;
261+
}
200262
}

tests/IntegrationTestCase.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ protected function setUp(): void
3131
$this->client = self::createClient();
3232
$this->client->disableReboot(); // prevent reboot to keep the transaction
3333

34+
// The DB is rolled back per test but Redis is not, so cached values keyed by package name
35+
// would leak into later tests that reuse a name. The cache client has its own DB in the
36+
// test env (REDIS_CACHE_URL) so this cannot clear state the default client owns.
37+
static::getContainer()->get('snc_redis.cache')->flushdb();
38+
3439
static::getService(Connection::class)->beginTransaction();
3540

3641
parent::setUp();

0 commit comments

Comments
 (0)