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}]"); 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. * Save cache to file.
*/ */

View File

@ -362,7 +362,8 @@ class ArtifactDownloader
if ($result !== null) { if ($result !== null) {
return $result; 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); $cache = ApplicationContext::get(ArtifactCache::class);
if ($prefer_source) { if ($prefer_source) {
@ -393,7 +394,33 @@ class ArtifactDownloader
'old_version' => $info['version'], '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 public function getRetry(): int
@ -411,6 +438,61 @@ class ArtifactDownloader
return $this->options[$name] ?? $default; 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 private function probeSourceCheckUpdate(Artifact $artifact, string $artifact_name): ?CheckUpdateResult
{ {
if (($callback = $artifact->getCustomSourceCheckUpdateCallback()) !== null) { if (($callback = $artifact->getCustomSourceCheckUpdateCallback()) !== null) {

View File

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

View File

@ -4,7 +4,10 @@ declare(strict_types=1);
namespace StaticPHP\Command; namespace StaticPHP\Command;
use StaticPHP\Artifact\ArtifactCache;
use StaticPHP\Artifact\ArtifactDownloader; use StaticPHP\Artifact\ArtifactDownloader;
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\SPCException; use StaticPHP\Exception\SPCException;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@ -17,9 +20,10 @@ class CheckUpdateCommand extends BaseCommand
public function configure(): void 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('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('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 // --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'); $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 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 { try {
$downloader = new ArtifactDownloader($this->input->getOptions()); $downloader = new ArtifactDownloader($this->input->getOptions());
$bare = (bool) $this->getOption('bare'); $bare = (bool) $this->getOption('bare');
if ($this->getOption('json')) { if ($this->getOption('json')) {
$results = $downloader->checkUpdates($artifacts, bare: $bare);
$outputs = []; $outputs = [];
foreach ($artifacts as $artifact) { foreach ($results as $artifact => $result) {
$result = $downloader->checkUpdate($artifact, bare: $bare);
$outputs[$artifact] = [ $outputs[$artifact] = [
'need-update' => $result->needUpdate, 'need-update' => $result->needUpdate,
'unsupported' => $result->unsupported,
'old' => $result->old, 'old' => $result->old,
'new' => $result->new, '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)); $this->output->writeln(json_encode($outputs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return static::OK; return static::OK;
} }
foreach ($artifacts as $artifact) { $downloader->checkUpdates($artifacts, bare: $bare, onResult: function (string $artifact, CheckUpdateResult $result) {
$result = $downloader->checkUpdate($artifact, bare: $bare); if ($result->unsupported) {
if (!$result->needUpdate) { $this->output->writeln("Artifact <info>{$artifact}</info> does not support update checking, <comment>skipped</comment>");
$this->output->writeln("Artifact <info>{$artifact}</info> is already up to date (<comment>{$result->new}</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 { } else {
[$old, $new] = [$result->old ?? 'unavailable', $result->new ?? 'unknown']; [$old, $new] = [$result->old ?? 'unavailable', $result->new ?? 'unknown'];
$this->output->writeln("Update available for <info>{$artifact}</info>: <comment>{$old}</comment> -> <comment>{$new}</comment>"); $this->output->writeln("Update available for <info>{$artifact}</info>: <comment>{$old}</comment> -> <comment>{$new}</comment>");
} }
} });
return static::OK; return static::OK;
} catch (SPCException $e) { } catch (SPCException $e) {
$e->setSimpleOutput(); $e->setSimpleOutput();

View File

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