-
Notifications
You must be signed in to change notification settings - Fork 3
When wikis have pending jobs, create a Kubernetes job to work through the items #603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cae47e0
88a2641
9b870d9
ba4e4a8
877690c
cf9c7db
1102404
c042a80
19ea494
2ccf1d8
f6241bd
1feb785
a2b0d42
380fef1
daa8893
2c46b47
790a4a6
9f65553
f1fb42a
374f4ef
6cca3e4
d6ef9df
d187f0c
2a5ffd4
8d0e591
8dedf28
01b0942
2bab770
a6b6e6f
3efdca7
df08fb6
5cc6dd3
38caebe
3bee2e8
0e10644
edf976c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| <?php | ||
|
|
||
| namespace App\Jobs; | ||
|
|
||
| use App\Wiki; | ||
| use Illuminate\Support\Facades\Http; | ||
| use Illuminate\Support\Facades\Log; | ||
|
|
||
| class PollForMediaWikiJobsJob extends Job | ||
| { | ||
| public function handle (): void | ||
| { | ||
| $allWikiDomains = Wiki::all()->pluck('domain'); | ||
| foreach ($allWikiDomains as $wikiDomain) { | ||
| if ($this->hasPendingJobs($wikiDomain)) { | ||
| $this->enqueueWiki($wikiDomain); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private function hasPendingJobs (string $wikiDomain): bool | ||
| { | ||
| $response = Http::withHeaders([ | ||
| 'host' => $wikiDomain | ||
| ])->get( | ||
| getenv('PLATFORM_MW_BACKEND_HOST').'/w/api.php?action=query&meta=siteinfo&siprop=statistics&format=json' | ||
| ); | ||
|
|
||
| if ($response->failed()) { | ||
m90 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| $this->job->markAsFailed(); | ||
| Log::error( | ||
| 'Failure polling wiki '.$wikiDomain.' for pending MediaWiki jobs: '.$response->clientError() | ||
| ); | ||
| return false; | ||
| } | ||
|
|
||
| $pendingJobsCount = data_get($response->json(), 'query.statistics.jobs', 0); | ||
| return $pendingJobsCount > 0; | ||
| } | ||
|
|
||
| private function enqueueWiki (string $wikiDomain): void | ||
| { | ||
| dispatch(new ProcessMediaWikiJobsJob($wikiDomain)); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want/need this to fan out to another job (as is) or should we rather merge the two jobs into one? |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| <?php | ||
|
|
||
| namespace App\Jobs; | ||
|
|
||
| use Illuminate\Bus\Queueable; | ||
| use Illuminate\Contracts\Queue\ShouldQueue; | ||
| use Illuminate\Contracts\Queue\ShouldBeUnique; | ||
| use Illuminate\Queue\InteractsWithQueue; | ||
| use Illuminate\Support\Facades\Log; | ||
| use Maclof\Kubernetes\Client; | ||
| use Maclof\Kubernetes\Models\Job as KubernetesJob; | ||
|
|
||
| class ProcessMediaWikiJobsJob implements ShouldQueue, ShouldBeUnique | ||
| { | ||
| use InteractsWithQueue, Queueable; | ||
|
|
||
| private string $wikiDomain; | ||
| private string $jobsKubernetesNamespace; | ||
|
|
||
| public function __construct (string $wikiDomain) | ||
| { | ||
| $this->wikiDomain = $wikiDomain; | ||
| $this->jobsKubernetesNamespace = env('API_JOB_NAMESPACE', 'api-jobs'); | ||
| } | ||
|
|
||
| public function uniqueId(): string | ||
| { | ||
| return $this->wikiDomain; | ||
| } | ||
|
|
||
| public function handle (Client $kubernetesClient): void | ||
| { | ||
| $kubernetesClient->setNamespace('default'); | ||
| $mediawikiPod = $kubernetesClient->pods()->setFieldSelector([ | ||
| 'status.phase' => 'Running' | ||
| ])->setLabelSelector([ | ||
| 'app.kubernetes.io/name' => 'mediawiki', | ||
| 'app.kubernetes.io/component' => 'app-backend' | ||
| ])->first(); | ||
|
|
||
| if ($mediawikiPod === null) { | ||
| $this->fail( | ||
| new \RuntimeException( | ||
| 'Unable to find a running MediaWiki pod in the cluster, '. | ||
| 'cannot continue.' | ||
| ) | ||
| ); | ||
| return; | ||
| } | ||
| $mediawikiPod = $mediawikiPod->toArray(); | ||
|
|
||
| $kubernetesClient->setNamespace($this->jobsKubernetesNamespace); | ||
| $jobSpec = new KubernetesJob([ | ||
| 'metadata' => [ | ||
| 'name' => 'run-all-mw-jobs-'.hash('sha1', $this->wikiDomain), | ||
| 'namespace' => $this->jobsKubernetesNamespace, | ||
| 'labels' => [ | ||
| 'app.kubernetes.io/instance' => $this->wikiDomain, | ||
| 'app.kubernetes.io/name' => 'run-all-mw-jobs' | ||
| ] | ||
| ], | ||
| 'spec' => [ | ||
| 'ttlSecondsAfterFinished' => 0, | ||
| 'template' => [ | ||
| 'metadata' => [ | ||
| 'name' => 'run-all-mw-jobs' | ||
| ], | ||
| 'spec' => [ | ||
| 'containers' => [ | ||
| 0 => [ | ||
| 'name' => 'run-all-mw-jobs', | ||
| 'image' => $mediawikiPod['spec']['containers'][0]['image'], | ||
| 'env' => array_merge( | ||
| $mediawikiPod['spec']['containers'][0]['env'], | ||
| [['name' => 'WBS_DOMAIN', 'value' => $this->wikiDomain]] | ||
| ), | ||
| 'command' => [ | ||
| 0 => 'bash', | ||
| 1 => '-c', | ||
| 2 => <<<'CMD' | ||
| JOBS_TO_GO=1 | ||
| while [ "$JOBS_TO_GO" != "0" ] | ||
| do | ||
| echo "Running 1000 jobs" | ||
| php w/maintenance/runJobs.php --maxjobs 1000 | ||
| echo Waiting for 1 seconds... | ||
| sleep 1 | ||
| JOBS_TO_GO=$(php w/maintenance/showJobs.php | tr -d '[:space:]') | ||
| echo $JOBS_TO_GO jobs to go | ||
| done | ||
| CMD | ||
| ], | ||
| ] | ||
| ], | ||
| 'restartPolicy' => 'Never' | ||
| ] | ||
| ] | ||
| ] | ||
| ]); | ||
|
|
||
| $job = $kubernetesClient->jobs()->apply($jobSpec); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Turns out that when using |
||
| $jobName = data_get($job, 'metadata.name'); | ||
| if (!$jobName) { | ||
| // The k8s client does not fail reliably on 4xx responses, so checking the name | ||
| // currently serves as poor man's error handling. | ||
| $this->fail( | ||
| new \RuntimeException('Job creation for wiki "'.$this->wikiDomain.'" failed.') | ||
| ); | ||
| return; | ||
| } | ||
| Log::info( | ||
| 'MediaWiki Job for wiki "'.$this->wikiDomain.'" exists or was created with name "'.$jobName.'".' | ||
| ); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| <?php | ||
|
|
||
| namespace Tests\Jobs; | ||
|
|
||
| use App\Wiki; | ||
| use App\Jobs\PollForMediaWikiJobsJob; | ||
| use App\Jobs\ProcessMediaWikiJobsJob; | ||
| use Tests\TestCase; | ||
| use Illuminate\Contracts\Queue\Job; | ||
| use Illuminate\Foundation\Testing\RefreshDatabase; | ||
| use Illuminate\Support\Facades\Http; | ||
| use Illuminate\Support\Facades\Bus; | ||
| use Illuminate\Database\Eloquent\Model; | ||
|
|
||
| class PollForMediaWikiJobsJobTest extends TestCase | ||
| { | ||
|
|
||
| use RefreshDatabase; | ||
|
|
||
| private Model $wiki; | ||
|
|
||
| public function setUp(): void | ||
| { | ||
| parent::setUp(); | ||
| $this->wiki = Wiki::factory()->create(); | ||
| } | ||
|
|
||
| public function testNoJobs() | ||
| { | ||
| Http::fake([ | ||
| getenv('PLATFORM_MW_BACKEND_HOST').'/w/api.php?action=query&meta=siteinfo&siprop=statistics&format=json' => Http::response([ | ||
| 'query' => [ | ||
| 'statistics' => [ | ||
| 'jobs' => 0 | ||
| ] | ||
| ] | ||
| ], 200) | ||
| ]); | ||
|
|
||
| Bus::fake(); | ||
| $mockJob = $this->createMock(Job::class); | ||
| $job = new PollForMediaWikiJobsJob(); | ||
| $job->setJob($mockJob); | ||
|
|
||
| $mockJob->expects($this->never())->method('fail'); | ||
| $mockJob->expects($this->never())->method('markAsFailed'); | ||
| $job->handle(); | ||
| Bus::assertNothingDispatched(); | ||
| } | ||
|
|
||
| public function testWithJobs() | ||
| { | ||
| Http::fake([ | ||
| getenv('PLATFORM_MW_BACKEND_HOST').'/w/api.php?action=query&meta=siteinfo&siprop=statistics&format=json' => Http::response([ | ||
| 'query' => [ | ||
| 'statistics' => [ | ||
| 'jobs' => 3 | ||
| ] | ||
| ] | ||
| ], 200) | ||
| ]); | ||
| Bus::fake(); | ||
|
|
||
| $mockJob = $this->createMock(Job::class); | ||
|
|
||
| $job = new PollForMediaWikiJobsJob(); | ||
| $job->setJob($mockJob); | ||
|
|
||
| $mockJob->expects($this->never())->method('fail'); | ||
| $mockJob->expects($this->never())->method('markAsFailed'); | ||
| $job->handle(); | ||
| Bus::assertDispatched(ProcessMediaWikiJobsJob::class); | ||
| } | ||
|
|
||
| public function testWithFailure() | ||
| { | ||
| Http::fake([ | ||
| getenv('PLATFORM_MW_BACKEND_HOST').'/w/api.php?action=query&meta=siteinfo&siprop=statistics&format=json' => Http::response([ | ||
| 'error' => 'Something went wrong' | ||
| ], 500) | ||
| ]); | ||
| Bus::fake(); | ||
|
|
||
| $mockJob = $this->createMock(Job::class); | ||
|
|
||
| $job = new PollForMediaWikiJobsJob(); | ||
| $job->setJob($mockJob); | ||
|
|
||
| $mockJob->expects($this->once())->method('markAsFailed'); | ||
| $mockJob->expects($this->never())->method('fail'); | ||
| $job->handle(); | ||
| Bus::assertNothingDispatched(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| <?php | ||
|
|
||
| namespace Tests\Jobs; | ||
|
|
||
| use Illuminate\Foundation\Testing\RefreshDatabase; | ||
| use Tests\TestCase; | ||
| use Illuminate\Contracts\Queue\Job; | ||
| use App\Jobs\ProcessMediaWikiJobsJob; | ||
| use Maclof\Kubernetes\Client; | ||
| use Http\Adapter\Guzzle6\Client as Guzzle6Client; | ||
| use GuzzleHttp\HandlerStack; | ||
| use GuzzleHttp\Handler\MockHandler; | ||
| use GuzzleHttp\Psr7\Response; | ||
|
|
||
| class ProcessMediaWikiJobsJobTest extends TestCase | ||
| { | ||
| use RefreshDatabase; | ||
|
|
||
| public function testJobFailOnNoMediaWikiPod() | ||
| { | ||
| $mockJob = $this->createMock(Job::class); | ||
| $mockJob->expects($this->once())->method('fail'); | ||
|
|
||
| $job = new ProcessMediaWikiJobsJob('test.wikibase.cloud'); | ||
| $job->setJob($mockJob); | ||
|
|
||
| $mock = new MockHandler([ | ||
| new Response(200, [], json_encode([ 'items' => [] ])), | ||
| ]); | ||
|
|
||
| $handlerStack = HandlerStack::create($mock); | ||
| $mockGuzzle = Guzzle6Client::createWithConfig([ | ||
| 'handler' => $handlerStack, | ||
| 'verify' => '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt', | ||
| ]); | ||
|
|
||
| $job->handle(new Client([ | ||
| 'master' => 'https://kubernetes.default.svc', | ||
| 'token' => '/var/run/secrets/kubernetes.io/serviceaccount/token', | ||
| ], null, $mockGuzzle)); | ||
| } | ||
|
|
||
| public function testJobDoesNotFail() | ||
| { | ||
| $mockJob = $this->createMock(Job::class); | ||
| $mockJob->expects($this->never())->method('fail'); | ||
|
|
||
| $job = new ProcessMediaWikiJobsJob('test.wikibase.cloud'); | ||
| $job->setJob($mockJob); | ||
|
|
||
| $mock = new MockHandler([ | ||
| new Response(200, [], json_encode([ 'items' => [ | ||
| [ | ||
| 'kind' => 'Pod', | ||
| 'spec' => [ | ||
| 'containers' => [ | ||
| [ | ||
| 'image' => 'helloworld', | ||
| 'env' => [ | ||
| 'SOMETHING' => 'something' | ||
| ] | ||
| ] | ||
| ] | ||
| ] | ||
| ] | ||
| ]])), | ||
| new Response(200, [], json_encode([ 'items' => [] ])), | ||
| new Response(201, [], json_encode([ | ||
| 'metadata' => [ | ||
| 'name' => 'some-job-name' | ||
| ] | ||
| ])) | ||
| ]); | ||
|
|
||
| $handlerStack = HandlerStack::create($mock); | ||
| $mockGuzzle = Guzzle6Client::createWithConfig([ | ||
| 'handler' => $handlerStack, | ||
| 'verify' => '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt', | ||
| ]); | ||
|
|
||
| $job->handle(new Client([ | ||
| 'master' => 'https://kubernetes.default.svc', | ||
| 'token' => '/var/run/secrets/kubernetes.io/serviceaccount/token', | ||
| ], null, $mockGuzzle)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we wanted to have this run at a higher frequency, we'd have two options:
I would guess the former is a bit cleaner.