Add parallel update checking and improve artifact update handling

This commit is contained in:
crazywhalecc 2026-03-05 11:11:31 +08:00
parent abdaaab6e6
commit 84f6dab882
No known key found for this signature in database
GPG Key ID: 1F4BDD59391F2680
5 changed files with 122 additions and 13 deletions

View File

@ -282,6 +282,16 @@ class ArtifactCache
logger()->debug("Removed binary cache entry for [{$artifact_name}] on platform [{$platform}]");
}
/**
* Get the names of all artifacts that have at least one downloaded entry (source or binary).
*
* @return array<string> Artifact names
*/
public function getCachedArtifactNames(): array
{
return array_keys($this->cache);
}
/**
* Save cache to file.
*/

View File

@ -362,7 +362,8 @@ class ArtifactDownloader
if ($result !== null) {
return $result;
}
throw new WrongUsageException("Artifact '{$artifact_name}' downloader does not support update checking.");
// logger()->warning("Artifact '{$artifact_name}' downloader does not support update checking, skipping.");
return new CheckUpdateResult(old: null, new: null, needUpdate: false, unsupported: true);
}
$cache = ApplicationContext::get(ArtifactCache::class);
if ($prefer_source) {
@ -393,7 +394,33 @@ class ArtifactDownloader
'old_version' => $info['version'],
]);
}
throw new WrongUsageException("Artifact '{$artifact_name}' downloader does not support update checking, exit.");
// logger()->warning("Artifact '{$artifact_name}' downloader does not support update checking, skipping.");
return new CheckUpdateResult(old: null, new: null, needUpdate: false, unsupported: true);
}
/**
* Check updates for multiple artifacts, with optional parallel processing.
*
* @param array<string> $artifact_names Artifact names to check
* @param bool $prefer_source Whether to prefer source over binary
* @param bool $bare Check without requiring artifact to be downloaded first
* @param null|callable $onResult Called immediately with (string $name, CheckUpdateResult) as each result arrives
* @return array<string, CheckUpdateResult> Results keyed by artifact name
*/
public function checkUpdates(array $artifact_names, bool $prefer_source = false, bool $bare = false, ?callable $onResult = null): array
{
if ($this->parallel > 1 && count($artifact_names) > 1) {
return $this->checkUpdatesWithConcurrency($artifact_names, $prefer_source, $bare, $onResult);
}
$results = [];
foreach ($artifact_names as $name) {
$result = $this->checkUpdate($name, $prefer_source, $bare);
$results[$name] = $result;
if ($onResult !== null) {
($onResult)($name, $result);
}
}
return $results;
}
public function getRetry(): int
@ -411,6 +438,61 @@ class ArtifactDownloader
return $this->options[$name] ?? $default;
}
private function checkUpdatesWithConcurrency(array $artifact_names, bool $prefer_source, bool $bare, ?callable $onResult): array
{
$results = [];
$fiber_pool = [];
$remaining = $artifact_names;
Shell::passthruCallback(function () {
\Fiber::suspend();
});
try {
while (!empty($remaining) || !empty($fiber_pool)) {
// fill pool
while (count($fiber_pool) < $this->parallel && !empty($remaining)) {
$name = array_shift($remaining);
$fiber = new \Fiber(function () use ($name, $prefer_source, $bare) {
return [$name, $this->checkUpdate($name, $prefer_source, $bare)];
});
$fiber->start();
$fiber_pool[$name] = $fiber;
}
// check pool
foreach ($fiber_pool as $fiber_name => $fiber) {
if ($fiber->isTerminated()) {
// getReturn() re-throws if the fiber threw — propagates immediately
[$artifact_name, $result] = $fiber->getReturn();
$results[$artifact_name] = $result;
if ($onResult !== null) {
($onResult)($artifact_name, $result);
}
unset($fiber_pool[$fiber_name]);
} else {
$fiber->resume();
}
}
}
} catch (\Throwable $e) {
// terminate all still-suspended fibers so their curl processes don't hang
foreach ($fiber_pool as $fiber) {
if (!$fiber->isTerminated()) {
try {
$fiber->throw($e);
} catch (\Throwable) {
// ignore — we only care about stopping them
}
}
}
throw $e;
} finally {
Shell::passthruCallback(null);
}
return $results;
}
private function probeSourceCheckUpdate(Artifact $artifact, string $artifact_name): ?CheckUpdateResult
{
if (($callback = $artifact->getCustomSourceCheckUpdateCallback()) !== null) {

View File

@ -8,7 +8,8 @@ readonly class CheckUpdateResult
{
public function __construct(
public ?string $old,
public string $new,
public ?string $new,
public bool $needUpdate,
public bool $unsupported = false,
) {}
}

View File

@ -4,7 +4,10 @@ declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Artifact\ArtifactCache;
use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
@ -17,9 +20,10 @@ class CheckUpdateCommand extends BaseCommand
public function configure(): void
{
$this->addArgument('artifact', InputArgument::REQUIRED, 'The name of the artifact(s) to check for updates, comma-separated');
$this->addArgument('artifact', InputArgument::OPTIONAL, 'The name of the artifact(s) to check for updates, comma-separated (default: all downloaded artifacts)');
$this->addOption('json', null, null, 'Output result in JSON format');
$this->addOption('bare', null, null, 'Check update without requiring the artifact to be downloaded first (old version will be null)');
$this->addOption('parallel', 'p', InputOption::VALUE_REQUIRED, 'Number of parallel update checks (default: 10)', 10);
// --with-php option for checking updates with a specific PHP version context
$this->addOption('with-php', null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.4)', '8.4');
@ -27,17 +31,27 @@ class CheckUpdateCommand extends BaseCommand
public function handle(): int
{
$artifacts = parse_comma_list($this->input->getArgument('artifact'));
$artifact_arg = $this->input->getArgument('artifact');
if ($artifact_arg === null) {
$artifacts = ApplicationContext::get(ArtifactCache::class)->getCachedArtifactNames();
if (empty($artifacts)) {
$this->output->writeln('<comment>No downloaded artifacts found.</comment>');
return static::OK;
}
} else {
$artifacts = parse_comma_list($artifact_arg);
}
try {
$downloader = new ArtifactDownloader($this->input->getOptions());
$bare = (bool) $this->getOption('bare');
if ($this->getOption('json')) {
$results = $downloader->checkUpdates($artifacts, bare: $bare);
$outputs = [];
foreach ($artifacts as $artifact) {
$result = $downloader->checkUpdate($artifact, bare: $bare);
foreach ($results as $artifact => $result) {
$outputs[$artifact] = [
'need-update' => $result->needUpdate,
'unsupported' => $result->unsupported,
'old' => $result->old,
'new' => $result->new,
];
@ -45,15 +59,17 @@ class CheckUpdateCommand extends BaseCommand
$this->output->writeln(json_encode($outputs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return static::OK;
}
foreach ($artifacts as $artifact) {
$result = $downloader->checkUpdate($artifact, bare: $bare);
if (!$result->needUpdate) {
$this->output->writeln("Artifact <info>{$artifact}</info> is already up to date (<comment>{$result->new}</comment>)");
$downloader->checkUpdates($artifacts, bare: $bare, onResult: function (string $artifact, CheckUpdateResult $result) {
if ($result->unsupported) {
$this->output->writeln("Artifact <info>{$artifact}</info> does not support update checking, <comment>skipped</comment>");
} elseif (!$result->needUpdate) {
$ver = $result->new ? "(<comment>{$result->new}</comment>)" : '';
$this->output->writeln("Artifact <info>{$artifact}</info> is already up to date {$ver}");
} else {
[$old, $new] = [$result->old ?? 'unavailable', $result->new ?? 'unknown'];
$this->output->writeln("Update available for <info>{$artifact}</info>: <comment>{$old}</comment> -> <comment>{$new}</comment>");
}
}
});
return static::OK;
} catch (SPCException $e) {
$e->setSimpleOutput();

View File

@ -104,7 +104,7 @@ const SPC_DOWNLOAD_TYPE_DISPLAY_NAME = [
'local' => 'local dir',
'pie' => 'PHP Installer for Extensions (PIE)',
'url' => 'url',
'php-release' => 'php.net',
'php-release' => 'PHP website release',
'custom' => 'custom downloader',
];