Skip to content
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

[2.x] Adds scheduler proxy command to normalize sub-minute schedules #167

Merged
merged 12 commits into from
Dec 19, 2023
Merged
98 changes: 98 additions & 0 deletions src/Console/Commands/VaporScheduleCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace Laravel\Vapor\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Str;

class VaporScheduleCommand extends Command
{
/**
* The console command name.
*
* @var string
*/
protected $signature = 'vapor:schedule';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Run the scheduled commands at the beginning of every minute';

/**
* Indicates whether the command should be shown in the Artisan command list.
*
* @var bool
*/
protected $hidden = true;

/**
* Execute the console command.
*/
public function handle(): int
{
if (! $cache = $this->ensureValidCacheDriver()) {
$this->call('schedule:run');

return 0;
}

$key = (string) Str::uuid();
$lockObtained = false;

while (true) {
if (! $lockObtained) {
$lockObtained = $this->obtainLock($cache, $key);
}

if ($lockObtained && now()->second === 0) {
$this->releaseLock($cache);

$this->call('schedule:run');

return 0;
}

if (! $lockObtained && now()->second === 0) {
return 1;
}

usleep(10000);
}
}

/**
* Ensure the cache driver is valid.
*/
protected function ensureValidCacheDriver(): ?Repository
{
$manager = $this->laravel['cache'];

if (in_array($manager->getDefaultDriver(), ['memcached', 'redis', 'dynamodb', 'database'])) {
return $manager->driver();
}

return null;
}

/**
* Obtain the lock for the schedule.
*/
protected function obtainLock(Repository $cache, string $key): bool
{
return $key === $cache->remember('vapor:schedule:lock', 60, function () use ($key) {
return $key;
});
}

/**
* Release the lock for the schedule.
*/
protected function releaseLock(Repository $cache): void
{
$cache->forget('vapor:schedule:lock');
}
}
7 changes: 6 additions & 1 deletion src/VaporServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Laravel\Vapor\Console\Commands\OctaneStatusCommand;
use Laravel\Vapor\Console\Commands\VaporHealthCheckCommand;
use Laravel\Vapor\Console\Commands\VaporQueueListFailedCommand;
use Laravel\Vapor\Console\Commands\VaporScheduleCommand;
use Laravel\Vapor\Console\Commands\VaporWorkCommand;
use Laravel\Vapor\Http\Controllers\SignedStorageUrlController;
use Laravel\Vapor\Http\Middleware\ServeStaticAssets;
Expand Down Expand Up @@ -173,7 +174,11 @@ protected function registerCommands()
return new VaporHealthCheckCommand;
});

$this->commands(['command.vapor.work', 'command.vapor.queue-failed', 'command.vapor.health-check']);
$this->app->singleton('command.vapor.schedule', function () {
return new VaporScheduleCommand;
});

$this->commands(['command.vapor.work', 'command.vapor.queue-failed', 'command.vapor.health-check', 'command.vapor.schedule']);
}

/**
Expand Down
75 changes: 75 additions & 0 deletions tests/Unit/VaporScheduleCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

namespace Laravel\Vapor\Tests\Unit;

use Carbon\Carbon;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Laravel\Vapor\VaporServiceProvider;
use Mockery;
use Orchestra\Testbench\TestCase;

class VaporScheduleCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

Carbon::setTestNow('2021-01-01 00:00:00');

Str::createUuidsUsing(function () {
return 'test-schedule-lock-key';
});
}

protected function getPackageProviders($app): array
{
return [
VaporServiceProvider::class,
];
}

public function test_scheduler_is_invoked_when_invalid_cache_is_configured()
{
$fake = Mockery::mock(Repository::class);
Cache::shouldReceive('getDefaultDriver')->once()->andReturn('array');
$fake->shouldNotReceive('remember');
if (version_compare($this->app->version(), 10, '>=')) {
$fake->shouldReceive('forget')->once()->with('illuminate:schedule:interrupt')->andReturn(true);
}
if (! Str::startsWith($this->app->version(), '9')) {
Cache::shouldReceive('driver')->once()->andReturn($fake);
}
$fake->shouldNotReceive('forget')->with('vapor:schedule:lock');

$this->artisan('vapor:schedule')
->assertExitCode(0);
}

public function test_scheduler_is_called_at_the_top_of_the_minute()
{
Cache::shouldReceive('getDefaultDriver')->once()->andReturn('dynamodb');
Cache::shouldReceive('driver')->andReturn($fake = Mockery::mock(Repository::class));
$fake->shouldReceive('remember')->once()->with('vapor:schedule:lock', 60, Mockery::any())->andReturn('test-schedule-lock-key');
if (version_compare($this->app->version(), 10, '>=')) {
$fake->shouldReceive('forget')->once()->with('illuminate:schedule:interrupt')->andReturn(true);
}
$fake->shouldReceive('forget')->once()->with('vapor:schedule:lock')->andReturn(true);

$this->artisan('vapor:schedule')
->assertExitCode(0);
}

public function test_scheduler_is_not_invoked_if_lock_cannot_be_obtained()
{
Cache::shouldReceive('getDefaultDriver')->once()->andReturn('dynamodb');
Cache::shouldReceive('driver')->andReturn($fake = Mockery::mock(Repository::class));
$fake->shouldReceive('remember')->once()->with('vapor:schedule:lock', 60, Mockery::any())->andReturn('test-locked-schedule-lock-key');
$fake->shouldNotReceive('forget')->with('illuminate:schedule:interrupt')->andReturn(true);
$fake->shouldNotReceive('forget')->with('vapor:schedule:lock');

$this->artisan('vapor:schedule')
->assertExitCode(1);
}
}