Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-pm4.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ on:
jobs:
run:
name: Run PM4-workflow
uses: processmaker/.github/.github/workflows/deploy-pm4.yml@main
uses: processmaker/.github/.github/workflows/deploy-pm4.yml@better-cleanup
secrets: inherit
111 changes: 111 additions & 0 deletions ProcessMaker/Console/Commands/ScheduleMultitenantRun.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

namespace ProcessMaker\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use ProcessMaker\Facades\Metrics;
use ProcessMaker\Multitenancy\Tenant;
use Throwable;

class ScheduleMultitenantRun extends Command
{
public const LOCK_KEY = 'schedule-multitenant-run';

public const LOCK_SECONDS = 600; // 10 minutes

public const DURATION_METRIC = 'schedule_multitenant_run_duration_seconds';

/**
* Histogram buckets in seconds, up to the command lock timeout.
*/
public const DURATION_BUCKETS = [1, 5, 15, 30, 60, 120, 300, 600];

/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'schedule:multitenant-run';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Run the scheduled commands for each tenant';

/**
* Execute the console command.
*/
public function handle(): int
{
$lock = Cache::lock(self::LOCK_KEY, self::LOCK_SECONDS);

if (!$lock->get()) {
$message = 'schedule:multitenant-run is already running; skipping this invocation.';
Log::error($message);
$this->error($message);

return self::FAILURE;
}

$startedAt = microtime(true);

try {
return $this->runSchedules();
} catch (Throwable $e) {
Log::error('schedule:multitenant-run failed: ' . $e->getMessage(), [
'exception' => $e,
]);

throw $e;
} finally {
$lock->release();
}
}

private function runSchedules(): int
{
if (config('app.multitenancy') === false) {
return $this->call('schedule:run');
}

$tenants = Tenant::query()->cursor();

$ranForTenant = false;

foreach ($tenants as $tenant) {
$ranForTenant = true;
$this->info("Running schedule for tenant [{$tenant->id}] {$tenant->name}");

$tenant->makeCurrent();

try {
// Schedule holds sticky CacheEventMutex/CacheSchedulingMutex instances.
// Forget it after makeCurrent so locks use this tenant's cache prefix.
// $this->forgetSchedule();
$this->call('schedule:run');
} finally {
Tenant::forgetCurrent();
// $this->forgetSchedule();
}
}

if (!$ranForTenant) {
$this->info('No tenants found.');
}

return self::SUCCESS;
}

/**
* Drop the Schedule singleton so the next resolve gets fresh mutexes/cache bindings.
*/
private function forgetSchedule(): void
{
app()->forgetInstance(Schedule::class);
}
}
14 changes: 14 additions & 0 deletions ProcessMaker/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use ProcessMaker\Console\Scheduling\FastSchedule;

class Kernel extends ConsoleKernel
{
Expand All @@ -14,6 +15,18 @@ class Kernel extends ConsoleKernel
*/
protected $commands = [];

/**
* Resolve a console schedule instance that runs Artisan commands in-process.
*
* @return Schedule
*/
public function resolveConsoleSchedule()
{
return tap(new FastSchedule($this->scheduleTimezone()), function ($schedule) {
$this->schedule($schedule->useCache($this->scheduleCache()));
});
}

/**
* Define the application's command schedule.
*
Expand All @@ -24,6 +37,7 @@ protected function schedule(Schedule $schedule)
{
$schedule->command('bpmn:timer')
->everyMinute()
->name('bpmn:timer')
->onOneServer()
->withoutOverlapping(config('app.scheduler.bpmn_timer_overlap_minutes', 5));

Expand Down
60 changes: 60 additions & 0 deletions ProcessMaker/Console/Scheduling/FastCommandEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace ProcessMaker\Console\Scheduling;

use Illuminate\Console\Scheduling\Event;
use Illuminate\Console\Scheduling\EventMutex;
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Console\Input\StringInput;

class FastCommandEvent extends Event
{
/**
* The Artisan command name or signature string.
*/
protected string $artisanCommand;

/**
* Parameters to pass to Artisan::call().
*
* @var array<string|int, mixed>
*/
protected array $artisanParameters;

/**
* @param array<string|int, mixed> $artisanParameters
* @param \DateTimeZone|string|null $timezone
*/
public function __construct(
EventMutex $mutex,
string $shellCommand,
string $artisanCommand,
array $artisanParameters = [],
$timezone = null
) {
parent::__construct($mutex, $shellCommand, $timezone);

$this->artisanCommand = $artisanCommand;
$this->artisanParameters = $artisanParameters;
}

public function artisanCommandName(): string
{
return (new StringInput($this->artisanCommand))->getFirstArgument() ?? $this->artisanCommand;
}

/**
* Run the command in-process, or shell out when runInBackground is set.
*
* @param \Illuminate\Contracts\Container\Container $container
* @return int
*/
protected function execute($container)
{
if ($this->runInBackground) {
return parent::execute($container);
}

return Artisan::call($this->artisanCommand, $this->artisanParameters);
}
}
86 changes: 86 additions & 0 deletions ProcessMaker/Console/Scheduling/FastSchedule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace ProcessMaker\Console\Scheduling;

use Illuminate\Console\Application;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Container\Container;
use Symfony\Component\Console\Command\Command as SymfonyCommand;

class FastSchedule extends Schedule
{
/**
* Add a new Artisan command event that runs in-process when possible.
*
* Falls back to a normal shell Event when the command string contains
* shell metacharacters (redirects, pipes, etc.).
*
* @param SymfonyCommand|string $command
* @param array $parameters
* @return \Illuminate\Console\Scheduling\Event
*/
public function command($command, array $parameters = [])
{
$commandDescription = null;

if ($command instanceof SymfonyCommand) {
$command = Container::getInstance()->make(get_class($command));
$commandDescription = $command->getDescription();
$artisanCommand = $command->getName();
} elseif (is_string($command) && class_exists($command)) {
$command = Container::getInstance()->make($command);
$commandDescription = $command->getDescription();
$artisanCommand = $command->getName();
} else {
$artisanCommand = $command;
}

$artisanSignature = $artisanCommand;
if ($parameters !== []) {
$artisanSignature .= ' ' . $this->compileParameters($parameters);
}

if ($this->containsShellMetacharacters($artisanSignature)) {
$event = parent::command($artisanCommand, $parameters);

if ($commandDescription !== null) {
$event->description($commandDescription);
}

return $event;
}

$shellCommand = Application::formatCommandString($artisanCommand);
if ($parameters !== []) {
$shellCommand .= ' ' . $this->compileParameters($parameters);
}

$this->events[] = $event = new FastCommandEvent(
$this->eventMutex,
$shellCommand,
$artisanCommand,
$parameters,
$this->timezone
);

$this->mergePendingAttributes($event);

if ($commandDescription !== null) {
$event->description($commandDescription);
}

if (empty($event->description)) {
$event->name($artisanSignature);
}

return $event;
}

/**
* Determine if the artisan signature requires a shell (redirects, pipes, etc.).
*/
protected function containsShellMetacharacters(string $command): bool
{
return (bool) preg_match('/(&&|&|\||>>|>)/', $command);
}
}
10 changes: 5 additions & 5 deletions ProcessMaker/Facades/Metrics.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
use ProcessMaker\Services\MetricsService;

/**
* @method static \Prometheus\Counter counter(string $name, string $help = null, array $labels = [])
* @method static \Prometheus\Gauge gauge(string $name, string $help = null, array $labels = [])
* @method static \Prometheus\Histogram histogram(string $name, string $help = null, array $labels = [], array $buckets = [0.1, 1, 5, 10])
* @method static \Prometheus\Counter counter(string $name, string|null $help = null, array $labels = [])
* @method static \Prometheus\Gauge gauge(string $name, string|null $help = null, array $labels = [])
* @method static \Prometheus\Histogram histogram(string $name, string|null $help = null, array $labels = [], array $buckets = [])
* @method static void setGauge(string $name, float $value, array $labelValues = [])
* @method static string renderMetrics()
* @method static \Prometheus\CollectorRegistry getCollectionRegistry()
* @method static void counterInc(string $name, string $help = null, array $labels = [])
* @method static void histogramObserve(string $name, string $help = null, array $labels = [], array $buckets = [0.1, 1, 5, 10], float $executionTime)
* @method static void counterInc(string $name, string|null $help = null, array $labels = [])
* @method static void histogramObserve(string $name, string|null $help = null, array $labels = [], array $buckets = [], float $executionTime = 0)
* @method static void clearMetrics()
*/
class Metrics extends Facade
Expand Down
Loading