diff --git a/core/Command/Background/JobsHistory.php b/core/Command/Background/JobsHistory.php new file mode 100644 index 0000000000000..8837bcc10cee9 --- /dev/null +++ b/core/Command/Background/JobsHistory.php @@ -0,0 +1,101 @@ +Run ID: job identifier as found in database (Snowflake ID) + - Class: class of the job + - Started at: start time of the job + - Server ID: server ID as defined in config.php (see `serverid`). Highlighted if it’s running on current server. + - PID: PID of process executing the job + - Duration: human readable duration + - Memory usage: human readable memory usage peak + + EOF; + + $this + ->setName('background-job:history') + ->setDescription('Show currently running jobs') + ->setHelp($help) + ->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Maximum number of results returned by the command', 200) + ->addOption('class', 'c', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Filter by class name', []) + ->addOption('status', 's', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Filter by status', []); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + $limit = (int)$input->getOption('limit'); + $classesId = $input->getOption('class'); + try { + $statuses = array_map(fn (string $value) => JobStatus::from((int)$value), $input->getOption('status')); + } catch (ValueError $e) { + $output->writeln('Invalid status provided'); + $output->writeln($e->getMessage()); + return Base::FAILURE; + } + $jobs = $this->jobRuns->completedJobs($statuses, $classesId, $limit); + $this->writeStreamingTableInOutputFormat($input, $output, $this->formatLine($jobs), 20); + + return Base::SUCCESS; + } + + private function formatLine(iterable $jobs): \Generator { + $jobsInfo = []; + $now = time(); + $currentServerId = $this->config->getSystemValueInt('serverid', -1); + foreach ($jobs as $job) { + $status = match ($job->status) { + JobStatus::RUNNING => 'Running', + JobStatus::SUCCEEDED => 'Succeeded', + JobStatus::FAILED => 'Failed', + JobStatus::CRASHED => 'Crashed', + default => 'Unknown', + }; + yield [ + 'Run ID' => $job->runId, + 'Status' => $status, + 'Class' => $job->className, + 'Started at' => $job->startedAt->format('Y-m-d H:i:s'), + 'Server ID' => $job->serverId === $currentServerId ? '' . $job->serverId . '' : $job->serverId, + 'PID' => $job->pid, + 'Duration' => $job->duration . ' ms', + 'Memory usage' => Util::humanFileSize($job->memoryPeak * 1024), + ]; + } + + return $jobsInfo; + } +} diff --git a/core/register_command.php b/core/register_command.php index 655bc5942daaf..1a7fd50349dcb 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -17,6 +17,7 @@ use OC\Core\Command\App\Update; use OC\Core\Command\Background\Delete; use OC\Core\Command\Background\Job; +use OC\Core\Command\Background\JobsHistory; use OC\Core\Command\Background\JobWorker; use OC\Core\Command\Background\ListCommand; use OC\Core\Command\Background\Mode; @@ -146,6 +147,7 @@ $application->add(Server::get(Delete::class)); $application->add(Server::get(JobWorker::class)); $application->add(Server::get(RunningJobs::class)); + $application->add(Server::get(JobsHistory::class)); $application->add(Server::get(Test::class)); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index a8e0024c23512..008a172f819c2 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1336,6 +1336,7 @@ 'OC\\Core\\Command\\Background\\Job' => $baseDir . '/core/Command/Background/Job.php', 'OC\\Core\\Command\\Background\\JobBase' => $baseDir . '/core/Command/Background/JobBase.php', 'OC\\Core\\Command\\Background\\JobWorker' => $baseDir . '/core/Command/Background/JobWorker.php', + 'OC\\Core\\Command\\Background\\JobsHistory' => $baseDir . '/core/Command/Background/JobsHistory.php', 'OC\\Core\\Command\\Background\\ListCommand' => $baseDir . '/core/Command/Background/ListCommand.php', 'OC\\Core\\Command\\Background\\Mode' => $baseDir . '/core/Command/Background/Mode.php', 'OC\\Core\\Command\\Background\\RunningJobs' => $baseDir . '/core/Command/Background/RunningJobs.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 935d548d29ce3..c92c28f5ec8ee 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1377,6 +1377,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Command\\Background\\Job' => __DIR__ . '/../../..' . '/core/Command/Background/Job.php', 'OC\\Core\\Command\\Background\\JobBase' => __DIR__ . '/../../..' . '/core/Command/Background/JobBase.php', 'OC\\Core\\Command\\Background\\JobWorker' => __DIR__ . '/../../..' . '/core/Command/Background/JobWorker.php', + 'OC\\Core\\Command\\Background\\JobsHistory' => __DIR__ . '/../../..' . '/core/Command/Background/JobsHistory.php', 'OC\\Core\\Command\\Background\\ListCommand' => __DIR__ . '/../../..' . '/core/Command/Background/ListCommand.php', 'OC\\Core\\Command\\Background\\Mode' => __DIR__ . '/../../..' . '/core/Command/Background/Mode.php', 'OC\\Core\\Command\\Background\\RunningJobs' => __DIR__ . '/../../..' . '/core/Command/Background/RunningJobs.php', diff --git a/lib/private/BackgroundJob/JobRuns.php b/lib/private/BackgroundJob/JobRuns.php index 6d3c857d62c6e..06a1662591055 100644 --- a/lib/private/BackgroundJob/JobRuns.php +++ b/lib/private/BackgroundJob/JobRuns.php @@ -8,13 +8,17 @@ */ namespace OC\BackgroundJob; +use Exception; use OCP\BackgroundJob\IJobRuns; use OCP\BackgroundJob\JobRun; use OCP\BackgroundJob\JobStatus; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\Snowflake\ISnowflakeDecoder; use OCP\Snowflake\ISnowflakeGenerator; use Override; +use Psr\Log\LoggerInterface; +use RuntimeException; final readonly class JobRuns implements IJobRuns { private const TABLE = 'job_runs'; @@ -23,7 +27,8 @@ public function __construct( private IDBConnection $connection, private ISnowflakeGenerator $snowflakeGenerator, private ISnowflakeDecoder $snowflakeDecoder, - private JobClassesRegistry $classesRegistry, + private JobClassesRegistry $jobClassesRegistry, + private LoggerInterface $logger, ) { } @@ -67,15 +72,57 @@ public function runningJobs(int $limit = 200): \Generator { ->executeQuery(); foreach ($result->iterateAssociative() as $row) { - $snowflakeInfo = $this->snowflakeDecoder->decode((string)$row['run_id']); - yield new JobRun( - $row['run_id'], - $this->classesRegistry->getName($row['class_id']), - $snowflakeInfo->getServerId(), - (int)$row['pid'], - $snowflakeInfo->getCreatedAt(), - JobStatus::from((int)$row['status']), - ); + yield $this->rowToJobRun($row); } } + + #[Override] + public function completedJobs(array $statuses = [], array $classes = [], int $limit = 200): \Generator { + if ($statuses === []) { + // By default, list only completed jobs + $statuses = [JobStatus::SUCCEEDED, JobStatus::FAILED, JobStatus::CRASHED]; + } + $dbStatuses = array_map(static fn (JobStatus $status) => $status->value, $statuses); + + $qb = $this->connection->getQueryBuilder(); + $qb + ->select('run_id', 'class_id', 'pid', 'status', 'duration', 'ram_peak_usage') + ->from(self::TABLE) + ->where($qb->expr()->in('status', $qb->createNamedParameter($dbStatuses, IQueryBuilder::PARAM_INT_ARRAY))) + ->setMaxResults($limit) + ->orderBy('run_id', 'DESC'); + + if ($classes !== []) { + $classIds = []; + foreach ($classes as $class) { + try { + $classIds[] = $this->jobClassesRegistry->getId($class); + } catch (Exception $e) { + $this->logger->warning('Fail to resolve background job class {class}', ['class' => $class, 'exception' => $e]); + } + } + if ($classIds === []) { + throw new RuntimeException('No class ID found for filtering'); + } + $qb->andWhere($qb->expr()->in('class_id', $qb->createNamedParameter($classIds, IQueryBuilder::PARAM_INT_ARRAY))); + } + + foreach ($qb->executeQuery()->iterateAssociative() as $row) { + yield $this->rowToJobRun($row); + } + } + + private function rowToJobRun(array $dbRow): JobRun { + $snowflakeInfo = $this->snowflakeDecoder->decode((string)$dbRow['run_id']); + return new JobRun( + $dbRow['run_id'], + $this->jobClassesRegistry->getName($dbRow['class_id']), + $snowflakeInfo->getServerId(), + (int)$dbRow['pid'], + $snowflakeInfo->getCreatedAt(), + JobStatus::from((int)$dbRow['status']), + isset($dbRow['duration']) ? (int)$dbRow['duration'] : null, + isset($dbRow['ram_peak_usage']) ? (int)$dbRow['ram_peak_usage'] : null, + ); + } } diff --git a/lib/public/BackgroundJob/IJobRuns.php b/lib/public/BackgroundJob/IJobRuns.php index a67fa82170602..276224b0e5298 100644 --- a/lib/public/BackgroundJob/IJobRuns.php +++ b/lib/public/BackgroundJob/IJobRuns.php @@ -22,4 +22,13 @@ interface IJobRuns { * @since 34.0.0 */ public function runningJobs(int $limit = 200): \Generator; + + /** + * List of completed jobs + * + * @param list $statuses + * @param list> $classes + * @since 34.0.0 + */ + public function completedJobs(array $statuses = [], array $classes = [], int $limit = 200): \Generator; } diff --git a/tests/lib/BackgroundJob/JobRunsTest.php b/tests/lib/BackgroundJob/JobRunsTest.php index ec1d4fe0c7f39..c32fe8ff8d096 100644 --- a/tests/lib/BackgroundJob/JobRunsTest.php +++ b/tests/lib/BackgroundJob/JobRunsTest.php @@ -18,6 +18,7 @@ use OCP\Snowflake\ISnowflakeDecoder; use OCP\Snowflake\ISnowflakeGenerator; use Override; +use Psr\Log\LoggerInterface; use Test\TestCase; /** @@ -40,6 +41,7 @@ protected function setUp(): void { Server::get(ISnowflakeGenerator::class), Server::get(ISnowflakeDecoder::class), $this->registry, + $this->createMock(LoggerInterface::class), ); } @@ -87,4 +89,23 @@ public function testRunningJobs(): void { } $this->assertGreaterThan(0, $runningJobs); } + + public function testCompletedJobs(): void { + $myPid = 1337; + $myClass = DummyJob::class; + + $runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid); + $this->runs->finished($runId, 12345, 67890 * 1024, JobStatus::FAILED); + $runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid); + $this->runs->finished($runId, 12345, 67890 * 1024, JobStatus::SUCCEEDED); + $runId = $this->runs->started($this->registry->getId(DummyJob::class), $myPid); + $this->runs->finished($runId, 12345, 67890 * 1024, JobStatus::CRASHED); + + $completedJobs = 0; + foreach ($this->runs->runningJobs() as $job) { + $this->assertInstanceOf(JobRun::class, $job); + ++$completedJobs; + } + $this->assertGreaterThan(0, $completedJobs); + } }