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 38cca5781f2b2..4c9fd0fe7c213 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;
@@ -150,6 +151,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 23c77bf3ec6ad..d37de0744f8fa 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 8d0e74d1cdf3c..522dafdb856b4 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);
+ }
}