Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions src/MarkClosedPluginAsAbandoned.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
use Composer\Composer;
use Composer\IO\IOInterface;
use Composer\Package\CompletePackageInterface;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\Cache as ApiCache;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\ArrayCache;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\CacheProxy;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\Client;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\FileCache;
use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\DownloadUrlParser;
use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\MultiUrlParser;
use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\SvnUrlParser;
Expand All @@ -23,8 +25,12 @@ public static function create(Composer $composer, IOInterface $io): self

$config = $composer->getConfig();
$cachePath = "{$config->get('cache-dir')}/wp-org-closed-plugin";
$cache = new Cache($io, $cachePath);
$cache->setReadOnly($config->get('cache-read-only'));
$composerCache = new Cache($io, $cachePath);
$composerCache->setReadOnly($config->get('cache-read-only'));
$cache = new CacheProxy(
new ArrayCache,
new FileCache($composerCache),
);

return new self(
new MultiUrlParser(
Expand All @@ -34,7 +40,7 @@ public static function create(Composer $composer, IOInterface $io): self
new Client(
$loop->getHttpDownloader(),
$loop,
new ApiCache($cache),
$cache,
),
$io,
);
Expand All @@ -46,6 +52,28 @@ public function __construct(
private IOInterface $io,
) {}

public function warmCache(CompletePackageInterface ...$packages): void
{
$slugs = array_map(
fn (CompletePackageInterface $package): ?string => $this->slug(
...$package->getDistUrls(),
...$package->getSourceUrls(),
),
$packages,
);
$slugs = array_filter($slugs, static fn (?string $slug) => $slug !== null);

$slugCount = count($slugs);
$message = sprintf(
'Warming cache for WordPress.org closed plugin status for %d %s',
$slugCount,
$slugCount === 1 ? 'slug' : 'slugs',
);
$this->io->debug($message);

$this->client->warmCache(...$slugs);
}

public function __invoke(CompletePackageInterface $package): void
{
$this->io->debug(
Expand Down
28 changes: 26 additions & 2 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,14 @@ public function uninstall(Composer $composer, IOInterface $io): void
public static function getSubscribedEvents(): array
{
return [
PackageEvents::PRE_PACKAGE_INSTALL => ['markClosedAsAbandoned', PHP_INT_MAX - 1000],
PackageEvents::PRE_PACKAGE_UPDATE => ['markClosedAsAbandoned', PHP_INT_MAX - 1000],
PackageEvents::PRE_PACKAGE_INSTALL => [
['warmCache', PHP_INT_MAX - 500],
['markClosedAsAbandoned', PHP_INT_MAX - 1000],
],
PackageEvents::PRE_PACKAGE_UPDATE => [
['warmCache', PHP_INT_MAX - 500],
['markClosedAsAbandoned', PHP_INT_MAX - 1000],
],

ScriptEvents::POST_INSTALL_CMD => ['markClosedLockedPackagesIfNotAlready', PHP_INT_MAX - 1000],
ScriptEvents::POST_UPDATE_CMD => ['markClosedLockedPackagesIfNotAlready', PHP_INT_MAX - 1000],
Expand All @@ -58,6 +64,22 @@ public static function getSubscribedEvents(): array
];
}

public function warmCache(PackageEvent $event): void
{
$packages = array_map(
static fn ($operation) => match (true) {
$operation instanceof InstallOperation => $operation->getPackage(),
$operation instanceof UpdateOperation => $operation->getTargetPackage(),
default => null,
},
$event->getOperations(),
);
$packages = array_filter($packages, static fn ($package) => $package instanceof CompletePackageInterface);
$packages = array_filter($packages, static fn ($package) => ! $package->isAbandoned());

$this->markClosedAsAbandoned->warmCache(...$packages);
}

public function markClosedAsAbandoned(PackageEvent $event): void
{
$operation = $event->getOperation();
Expand Down Expand Up @@ -95,6 +117,8 @@ public function markClosedLockedPackagesIfNotAlready(ScriptEvent $event): void
fn ($package) => ! in_array($package->getPrettyName(), $this->marked, true),
);

$this->markClosedAsAbandoned->warmCache(...$packages);

foreach ($packages as $package) {
$this->marked[] = $package->getPrettyName();
$this->markClosedAsAbandoned->__invoke($package);
Expand Down
21 changes: 21 additions & 0 deletions src/WpOrg/Api/ArrayCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace TypistTech\WpOrgClosedPlugin\WpOrg\Api;

class ArrayCache implements CacheInterface
{
/** @var array<string, bool> */
private array $data = [];

public function read(string $slug): ?bool
{
return $this->data[$slug] ?? null;
}

public function write(string $slug, bool $isClosed): void
{
$this->data[$slug] = $isClosed;
}
}
12 changes: 12 additions & 0 deletions src/WpOrg/Api/CacheInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace TypistTech\WpOrgClosedPlugin\WpOrg\Api;

interface CacheInterface
{
public function read(string $slug): ?bool;

public function write(string $slug, bool $isClosed): void;
}
34 changes: 34 additions & 0 deletions src/WpOrg/Api/CacheProxy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace TypistTech\WpOrgClosedPlugin\WpOrg\Api;

readonly class CacheProxy implements CacheInterface
{
public function __construct(
private CacheInterface $fast,
private CacheInterface $slow,
) {}

public function read(string $slug): ?bool
{
$result = $this->fast->read($slug);
if ($result !== null) {
return $result;
}

$result = $this->slow->read($slug);
if ($result !== null) {
$this->fast->write($slug, $result);
}

return $result;
}

public function write(string $slug, bool $isClosed): void
{
$this->fast->write($slug, $isClosed);
$this->slow->write($slug, $isClosed);
}
}
15 changes: 14 additions & 1 deletion src/WpOrg/Api/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,22 @@
public function __construct(
private HttpDownloader $httpDownloader,
private Loop $loop,
private Cache $cache,
private CacheInterface $cache,
) {}

public function warmCache(string ...$slugs): void
{
$slugs = array_map('trim', $slugs);
$slugs = array_filter($slugs, static fn (string $slug) => $slug !== '');

$promises = array_map(
fn (string $slug) => $this->isClosedAsync($slug),
$slugs,
);

$this->loop->wait($promises);
Comment on lines +26 to +31
Copy link
Member Author

@tangrufus tangrufus Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Help wanted!

  1. How to assert the promises (to be exact, the underlying HTTP requests) are performed concurrently?
  2. Should I assert they are performed concurrently?
  3. How to refactor it for easiler testing?

Copy link
Member Author

@tangrufus tangrufus Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manually install ~100 wp.org plugins and look at the cache generation, it feels like the promises are performed concurrently.

Still don't have a way to assert it pragmatically

}

public function isClosed(string $slug): bool
{
$slug = trim($slug);
Expand Down
4 changes: 2 additions & 2 deletions src/WpOrg/Api/Cache.php → src/WpOrg/Api/FileCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
use Composer\Cache as ComposerCache;
use DateTimeInterface;

class Cache
readonly class FileCache implements CacheInterface
{
private const int TTL = 600;

public function __construct(
private readonly ComposerCache $cache,
private ComposerCache $cache,
) {}

public function read(string $slug): ?bool
Expand Down
8 changes: 4 additions & 4 deletions tests/Feature/WpOrg/Api/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use Composer\Util\HttpDownloader;
use Mockery;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\Cache;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\CacheInterface;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\Client;

covers(Client::class);
Expand Down Expand Up @@ -49,7 +49,7 @@
it('returns true if and only if the plugin is closed', function (string $slug, bool $expected): void {
$loop = $this->loop();
$httpDownloader = $loop->getHttpDownloader();
$cache = Mockery::spy(Cache::class);
$cache = Mockery::spy(CacheInterface::class);

$client = new Client($httpDownloader, $loop, $cache);

Expand All @@ -61,7 +61,7 @@
it('writes to cache', function (string $slug, bool $expected): void {
$loop = $this->loop();
$httpDownloader = $loop->getHttpDownloader();
$cache = Mockery::spy(Cache::class);
$cache = Mockery::spy(CacheInterface::class);

$client = new Client($httpDownloader, $loop, $cache);

Expand All @@ -75,7 +75,7 @@
$loop = $this->loop();
$httpDownloader = Mockery::spy(HttpDownloader::class);

$cache = Mockery::spy(Cache::class);
$cache = Mockery::spy(CacheInterface::class);
$cache->expects()
->read()
->with($slug)
Expand Down
61 changes: 61 additions & 0 deletions tests/Unit/WpOrg/Api/ArrayCacheTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\WpOrg\Api;

use TypistTech\WpOrgClosedPlugin\WpOrg\Api\ArrayCache;
use TypistTech\WpOrgClosedPlugin\WpOrg\Api\CacheInterface;

covers(ArrayCache::class);

describe(ArrayCache::class, static function (): void {
it('implements CacheInterface', function (): void {
$cache = new ArrayCache;

expect($cache)->toBeInstanceOf(CacheInterface::class);
});

describe('::read()', static function (): void {
test('hit', function (bool $expected): void {
$cache = new ArrayCache;
$cache->write('foo', $expected);

$actual = $cache->read('foo');

expect($actual)->toBe($expected);
})->with([true, false]);

test('miss', function (): void {
$cache = new ArrayCache;

$actual = $cache->read('foo');

expect($actual)->toBeNull();
});
});

describe('::write()', static function (): void {
it('stores', function (bool $isClosed): void {
$cache = new ArrayCache;

$cache->write('foo', $isClosed);

expect($cache->read('foo'))->toBe($isClosed);
})->with([true, false]);

test('last write wins', function (bool $first, bool $last): void {
$cache = new ArrayCache;

$cache->write('foo', $first);
$cache->write('foo', $last);

expect($cache->read('foo'))->toBe($last);
})->with([
[true, false],
[false, true],
[true, true],
[false, false],
]);
});
});
Loading
Loading