<?php

namespace Qless\Queues;

use Qless\Client;
use Qless\Events\User\Queue as QueueEvent;
use Qless\EventsManagerAwareInterface;
use Qless\EventsManagerAwareTrait;
use Qless\Exceptions\InvalidArgumentException;
use Qless\Exceptions\QlessException;
use Qless\Exceptions\RuntimeException;
use Qless\Exceptions\UnknownPropertyException;
use Qless\Jobs\BaseJob;
use Qless\Jobs\JobData;
use Ramsey\Uuid\Uuid;

/**
 * Qless\Queues\Queue
 *
 * @package Qless\Queues
 *
 * @property int $heartbeat get / set the heartbeat timeout for the queue
 */
class Queue implements EventsManagerAwareInterface
{
    use EventsManagerAwareTrait;

    /** @var Client */
    private $client;

    /** @var string */
    private $name;

    /**
     * Queue constructor.
     *
     * @param string $name
     * @param Client $client
     */
    public function __construct(string $name, Client $client)
    {
        $this->client = $client;
        $this->name   = $name;

        $this->setEventsManager($this->client->getEventsManager());
        $this->registerSyncCompleteEvent();
    }

    /**
     * Put the described job in this queue.
     *
     * Either create a new job in the provided queue with the provided attributes,
     * or move that job into that queue. If the job is being serviced by a worker,
     * subsequent attempts by that worker to either `heartbeat` or `complete` the
     * job should fail and return `false`.
     *
     * @param  string        $className The class with the job perform method.
     * @param  array         $data      An array of parameters for job.
     * @param  string|null   $jid       The specified job id, if not a specified, a jid will be generated.
     * @param  int|null      $delay     The specified delay to run job.
     * @param  int|null      $retries   Number of retries allowed.
     * @param  int|null      $priority  A greater priority will execute before jobs of lower priority.
     * @param  string[]|null $tags      An array of tags to add to the job.
     * @param  string[]|null $depends   A list of JIDs this job must wait on before executing.
     * @return string The job identifier.
     *
     * @throws QlessException
     * @throws RuntimeException
     */
    public function put(
        string $className,
        array $data,
        ?string $jid = null,
        ?int $delay = null,
        ?int $retries = null,
        ?int $priority = null,
        ?array $tags = null,
        ?array $depends = null
    ) {
        try {
            $jid = $jid ?: str_replace('-', '', Uuid::uuid4()->toString());
        } catch (\Exception $e) {
            throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
        }

        $data = new JobData($data);
        $this->getEventsManager()->fire(new QueueEvent\BeforeEnqueue($this, $jid, $data, $className));

        if (!$putData = json_encode($data, JSON_UNESCAPED_SLASHES)) {
            throw new RuntimeException(
                sprintf(
                    'Unable to encode payload to put the described job "%s" to the "%s" queue.',
                    $jid,
                    $this->name
                )
            );
        }

        $jid = $this->client->put(
            '',
            $this->name,
            $jid,
            $className,
            $putData,
            is_null($delay) ? 0 : $delay,
            'priority',
            is_null($priority) ? 0 : $priority,
            'tags',
            json_encode($tags ?: [], JSON_UNESCAPED_SLASHES),
            'retries',
            is_null($retries) ? 5 : $retries,
            'depends',
            json_encode($depends ?: [], JSON_UNESCAPED_SLASHES)
        );

        $this->getEventsManager()->fire(new QueueEvent\AfterEnqueue($this, $jid, $data->toArray(), $className));

        return $jid;
    }

    /**
     * Get the next job on this queue.
     *
     * @param string|null $worker  Worker name popping the job.
     * @param int         $numJobs Number of jobs to pop off of the queue.
     *
     * @return BaseJob|BaseJob[]|null
     *
     * @throws QlessException
     */
    public function pop(?string $worker = null, ?int $numJobs = null)
    {
        $workerName = $worker ?: $this->client->getWorkerName();
        $jids = json_decode($this->client->pop($this->name, $workerName, $numJobs ?: 1), true);

        $jobs = [];
        array_map(function (array $data) use (&$jobs) {
            $job = new BaseJob($this->client, $data);
            $job->setEventsManager($this->getEventsManager());

            $jobs[] = $job;
        }, $jids ?: []);

        return $numJobs === null ? array_shift($jobs) : $jobs;
    }

    /**
     * Get job by JID from this queue.
     *
     * @param string $jid
     * @param string|null $worker
     * @return BaseJob|null
     */
    public function popByJid(string $jid, ?string $worker = null): ?BaseJob
    {
        $workerName = $worker ?: $this->client->getWorkerName();
        $data = json_decode($this->client->popByJid($this->name, $jid, $workerName), true);
        $jobData = array_reduce($data, 'array_merge', []); //unwrap nested array

        if (isset($jobData['jid']) === false) {
            return null;
        }

        if ($jobData['jid'] === $jid) {
            $job = new BaseJob($this->client, $jobData);
            $job->setEventsManager($this->getEventsManager());
        }

        return $job ?? null;
    }

    /**
     * Make a recurring job in this queue.
     *
     * @param  string      $className The class with the job perform method.
     * @param  array       $data      An array of parameters for job.
     * @param  int|null    $interval  The recurring interval in seconds.
     * @param  int|null    $offset    A delay before the first run in seconds.
     * @param  string|null $jid       The specified job id, if not a specified, a jid will be generated.
     * @param  int|null    $retries   Number of times the job can retry when it runs.
     * @param  int|null    $priority  A negative priority will run sooner.
     * @param  int|null    $backlog
     * @param  array|null  $tags      An array of tags to add to the job.
     *
     * @return string
     *
     * @throws QlessException
     * @throws RuntimeException
     */
    public function recur(
        string $className,
        array $data,
        ?int $interval = null,
        ?int $offset = null,
        ?string $jid = null,
        ?int $retries = null,
        ?int $priority = null,
        ?int $backlog = null,
        ?array $tags = null
    ) {
        try {
            $jid = $jid ?: Uuid::uuid4()->toString();
        } catch (\Exception $e) {
            throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
        }

        $data = json_encode($data, JSON_UNESCAPED_SLASHES);
        if (empty($data)) {
            throw new RuntimeException(
                sprintf(
                    'Unable to encode payload to make a recurring job "%s" for the "%s" queue.',
                    $jid,
                    $this->name
                )
            );
        }

        return $this->client->recur(
            $this->name,
            $jid,
            $className,
            $data,
            'interval',
            is_null($interval) ? 60 : $interval,
            is_null($offset) ? 0 : $offset,
            'priority',
            is_null($priority) ? 0 : $priority,
            'tags',
            json_encode($tags ?: [], JSON_UNESCAPED_SLASHES),
            'retries',
            is_null($retries) ? 5 : $retries,
            'backlog',
            is_null($backlog) ? 0 : $backlog
        );
    }

    /**
     * Cancels a job using the specified identifier.
     *
     * @param string $jid
     *
     * @return array
     */
    public function cancel(string $jid): array
    {
        return $this->client->cancel($jid);
    }

    /**
     * Remove a recurring job using the specified identifier.
     *
     * @param string $jid
     *
     * @return int
     */
    public function unrecur(string $jid)
    {
        return $this->client->unrecur($jid);
    }

    /**
     * Get the length of the queue.
     *
     * @return int
     */
    public function length(): int
    {
        return $this->client->length($this->name);
    }

    /**
     * @param  string $name
     * @return int
     *
     * @throws UnknownPropertyException
     * @throws QlessException
     */
    public function __get(string $name)
    {
        switch ($name) {
            case 'heartbeat':
                $fallback = $this->client->config->get('heartbeat', 60);

                return (int) $this->client->config->get("{$this->name}-heartbeat", $fallback);
            default:
                throw new UnknownPropertyException('Getting unknown property: ' . self::class . '::' . $name);
        }
    }

    /**
     * The magic setter to update Queue's properties.
     *
     * @param  string $name
     * @param  mixed  $value
     * @return void
     *
     * @throws UnknownPropertyException
     * @throws InvalidArgumentException
     * @throws QlessException
     */
    public function __set(string $name, $value): void
    {
        switch ($name) {
            case 'heartbeat':
                if (!is_int($value)) {
                    throw new InvalidArgumentException('heartbeat must be an int');
                }

                $this->client
                    ->config
                    ->set("{$this->name}-heartbeat", $value);
                break;
            default:
                throw new UnknownPropertyException('Setting unknown property: ' . self::class . '::' . $name);
        }
    }

    /**
     * @param  string $name
     * @return void
     */
    public function __unset(string $name): void
    {
        switch ($name) {
            case 'heartbeat':
                try {
                    $this->client
                        ->config
                        ->clear("{$this->name}-heartbeat");
                } catch (\Throwable $e) {
                    // The unset shouldn't throw any exception. Thus do nothing
                }
                break;
            default:
                // The unset shouldn't throw any exception. Thus do nothing
        }
    }

    /**
     * Return the current statistics for a given queue on a given date.
     *
     * The results are returned are a JSON blob:
     * <code>
     * {
     *     # These are unimplemented as of yet
     *     'failed': 3,
     *     'retries': 5,
     *     'failures': 5,
     *     'wait': {
     *         'total'    : ...,
     *         'mean'     : ...,
     *         'variance' : ...,
     *         'histogram': [
     *             ...
     *         ]
     *     },
     *     'run': {
     *         'total'    : ...,
     *         'mean'     : ...,
     *         'variance' : ...,
     *         'histogram': [
     *             ...
     *         ]
     *     },
     * }
     * </code>
     *
     * @param  int $date The date for which stats to retrieve as a unix timestamp.
     * @return array
     *
     * @throws QlessException
     */
    public function stats(?int $date = null): array
    {
        $date = $date ?: time();

        return json_decode($this->client->stats($this->name, $date), true);
    }

    /**
     * Pauses this queue so it will not process any more Jobs.
     *
     * @return void
     */
    public function pause(): void
    {
        $this->client->pause($this->name);
    }

    /**
     * Resumes the queue so it will continue processing jobs.
     *
     * @return void
     */
    public function resume(): void
    {
        $this->client->unpause($this->name);
    }

    /**
     * Checks if this queue is paused.
     *
     * @return bool
     */
    public function isPaused(): bool
    {
        $stat = json_decode($this->client->queues($this->name), true);

        return isset($stat['name']) && $stat['name'] === $this->name && $stat['paused'] == true;
    }

    /**
     * @param string $topicPattern
     * @return bool
     */
    public function subscribe(string $topicPattern): bool
    {
        $subscriptions = $this->client->subscription($this->name, 'add', $topicPattern);
        $subscriptions = json_decode($subscriptions, true);

        return in_array($topicPattern, $subscriptions);
    }

    /**
     * @param string $topicPattern
     * @return bool
     */
    public function unSubscribe(string $topicPattern): bool
    {
        return $this->client->subscription($this->name, 'remove', $topicPattern) == 'true';
    }

    /**
     * Immediately handle job if sync mode enabled
     */
    private function registerSyncCompleteEvent(): void
    {
        $this->getEventsManager()
            ->attach(QueueEvent\AfterEnqueue::getName(), function (QueueEvent\AfterEnqueue $event) {
                if (!$this->client->config->get('sync-enabled')) {
                    return;
                }
                $job = $this->popByJid($event->getJid());
                if (!empty($job)) {
                    $job->perform();
                }
            });
    }

    /**
     * Gets this queue name.
     *
     * @return string
     */
    public function __toString(): string
    {
        return $this->name;
    }
}


<?php

namespace Qless;

use SplPriorityQueue;
use Qless\Exceptions\InvalidArgumentException;
use Qless\Events\User\AbstractEvent as AbstractUserEvent;

/**
 * Qless\EventsManager
 *
 * @package Qless
 */
final class EventsManager
{
    /** @var SplPriorityQueue[] */
    private $events = [];

    /**
     * Attach a listener to the events manager.
     *
     * @param  string          $eventName
     * @param  object|callable $handler
     * @param  int             $priority
     * @return void
     *
     * @throws InvalidArgumentException
     */
    public function attach(string $eventName, $handler, int $priority = 100): void
    {
        if (is_object($handler) == false && is_callable($handler) == false) {
            throw new InvalidArgumentException(
                sprintf('Event handler must be either an object or a callable %s given.', gettype($handler))
            );
        }

        $priorityQueue = $this->fetchQueue($eventName);
        $priorityQueue->insert($handler, $priority);
    }

    /**
     * Fetches a priority events queue by event type.
     *
     * @param  string $eventName
     * @return SplPriorityQueue
     */
    protected function fetchQueue(string $eventName): SplPriorityQueue
    {
        if (isset($this->events[$eventName]) == false) {
            $this->events[$eventName] = $this->createQueue();
        }

        return $this->events[$eventName];
    }

    /**
     * Creates a priority events queue.
     *
     * @return SplPriorityQueue
     */
    protected function createQueue(): SplPriorityQueue
    {
        $priorityQueue = new SplPriorityQueue();
        $priorityQueue->setExtractFlags(SplPriorityQueue::EXTR_DATA);

        return $priorityQueue;
    }

    /**
     * Detach the listener from the events manager.
     *
     * @param  string          $eventName
     * @param  object|callable $handler
     * @return void
     *
     * @throws InvalidArgumentException
     */
    public function detach(string $eventName, $handler): void
    {
        if (is_object($handler) == false && is_callable($handler) == false) {
            throw new InvalidArgumentException(
                sprintf('Event handler must be either an object or a callable %s given.', gettype($handler))
            );
        }

        if (isset($this->events[$eventName]) == false) {
            return;
        }

        $priorityQueue = $this->events[$eventName];
        $priorityQueue->setExtractFlags(SplPriorityQueue::EXTR_BOTH);
        $priorityQueue->top();

        $newPriorityQueue = $this->createQueue();

        while ($priorityQueue->valid()) {
            $data = $priorityQueue->current();
            $priorityQueue->next();

            if ($data['data'] !== $handler) {
                $newPriorityQueue->insert($data['data'], $data['priority']);
            }
        }

        $this->events[$eventName] = $newPriorityQueue;
    }

    /**
     * Fires an event in the events manager causing the active listeners to be notified about it.
     *
     * @param AbstractUserEvent $event
     * @return mixed|null
     */
    public function fire(AbstractUserEvent $event)
    {
        $status = null;

        $type = $event::getEntityName();
        if (isset($this->events[$type])) {
            $queue = $this->events[$type];
            $status = $this->fireQueue($queue, $event);
        }

        $eventName = $event->getName();
        if (isset($this->events[$eventName])) {
            $queue = $this->events[$eventName];
            $status = $this->fireQueue($queue, $event);
        }

        return $status;
    }

    /**
     * Internal handler to call a queue of events.
     *
     * @param  SplPriorityQueue $queue
     * @param  AbstractUserEvent $event
     * @return mixed|null
     */
    private function fireQueue(SplPriorityQueue $queue, AbstractUserEvent $event)
    {
        $eventHappening = $event::getHappening();

        $iterator = clone $queue;
        $iterator->top();

        $arguments = null;
        $status = null;

        while ($iterator->valid()) {
            $handler = $iterator->current();
            $iterator->next();

            if (is_object($handler)) {
                if ($handler instanceof \Closure) {
                    $arguments = $arguments ?: [$event];
                    $status = call_user_func_array($handler, $arguments);
                } else {
                    if (method_exists($handler, $eventHappening)) {
                        $status = $handler->{$eventHappening}($event);
                    }
                }
            }
        }

        return $status;
    }
}


<?php

namespace Qless\Workers;

use Closure;
use Psr\Log\LoggerInterface;
use Qless\Events\User\Job as JobEvent;
use Qless\Events\User\Worker as WorkerEvent;
use Qless\EventsManagerAwareInterface;
use Qless\Exceptions\ErrorFormatter;
use Qless\Exceptions\RuntimeException;
use Qless\Jobs\BaseJob;
use Qless\Signals\SignalHandler;
use Qless\Subscribers\SignalsAwareSubscriber;
use Qless\Subscribers\WatchdogSubscriber;

/**
 * Qless\Workers\ForkingWorker
 *
 * @todo This class is still needs to be refactored.
 *
 * @package Qless\Workers
 */
final class ForkingWorker extends AbstractWorker
{
    private const PROCESS_TYPE_MASTER = 0;
    private const PROCESS_TYPE_JOB = 1;
    private const PROCESS_TYPE_WATCHDOG = 2;

    /** @var int */
    private $processType = self::PROCESS_TYPE_MASTER;

    /**
     * Process ID of child worker processes.
     * @var ?int
     */
    private $childPID = null;

    /** @var ?int */
    private $watchdogPID = null;

    /** @var int */
    private $childProcesses = 0;

    /** @var string */
    private $who = 'master';

    /** @var array */
    private $logContext = [];

    /** @var resource[] */
    private $sockets = [];

    /** @var SignalsAwareSubscriber */
    private $signalsSubscriber;

    /**
     * {@inheritdoc}
     *
     * @return void
     */
    public function onConstruct(): void
    {
        $this->signalsSubscriber = new SignalsAwareSubscriber($this->logger);
        $this->getEventsManager()->attach(WorkerEvent\AbstractWorkerEvent::getEntityName(), $this->signalsSubscriber);
    }

    /**
     * {@inheritdoc}
     *
     * @param  LoggerInterface $logger
     * @return void
     */
    public function setLogger(LoggerInterface $logger): void
    {
        parent::setLogger($logger);

        $this->signalsSubscriber->setLogger($logger);
    }

    /**
     * {@inheritdoc}
     *
     * @return void
     */
    public function perform(): void
    {
        declare(ticks=1);

        $this->who = 'master:' . $this->name;
        $this->logContext = ['type' => $this->who, 'job.identifier' => null];
        $this->logger->info('{type}: worker started', $this->logContext);

        $this->reserver->beforeWork();

        $didWork = false;

        while (true) {
            // Don't wait on any processes if we're already in shutdown mode.
            if ($this->isShuttingDown() == true) {
                break;
            }

            while ($this->paused) {
                usleep(250000);
            }

            if ($didWork) {
                $this->title(
                    sprintf(
                        'Waiting for %s with interval %d sec',
                        implode(',', $this->reserver->getQueues()),
                        $this->interval
                    )
                );
                $didWork = false;
            }

            $job = $this->reserve();
            if ($job === null) {
                if ($this->interval == 0) {
                    break;
                }
                usleep($this->interval * 1000000);
                continue;
            }

            $this->setCurrentJob($job);
            $this->logContext['job.identifier'] = $job->jid;

            // fork processes
            $this->childStart();
            $this->watchdogStart($this->client->createSubscriber(['ql:log']));

            $this->title(sprintf('Forked %d at %s', $this->childPID, strftime('%F %T')));

            // Parent process, sit and wait
            while ($this->childProcesses > 0) {
                $status = null;
                $pid   = pcntl_wait($status, WUNTRACED);
                if ($pid > 0) {
                    if ($pid === $this->childPID) {
                        $exited = $this->childProcessStatus($status);
                    } elseif ($pid === $this->watchdogPID) {
                        $exited = $this->watchdogProcessStatus($status);
                    } else {
                        // unexpected?
                        $this->logger->info(sprintf("master received status for unknown PID %d; exiting\n", $pid));
                        exit(1);
                    }

                    if ($exited) {
                        --$this->childProcesses;
                        switch ($pid) {
                            case $this->childPID:
                                $this->childPID = null;
                                if ($this->watchdogPID) {
                                    // shutdown watchdog immediately if child has exited
                                    posix_kill($this->watchdogPID, SIGKILL);
                                }
                                break;

                            case $this->watchdogPID:
                                $this->watchdogPID = null;
                                break;
                        }
                    }
                }
            }

            foreach ($this->sockets as $socket) {
                socket_close($socket);
            }

            $this->sockets  = [];
            $this->setCurrentJob(null);
            $this->logContext['job.identifier'] = null;
            $didWork = true;

            /**
             * We need to reconnect due to bug in Redis library that always sends QUIT on destruction of \Redis
             * rather than just leaving socket around. This call will sometimes generate a broken pipe notice
             */
            $old = error_reporting();
            error_reporting($old & ~E_NOTICE);
            try {
                $this->client->reconnect();
            } finally {
                error_reporting($old);
            }
        }
    }

    /**
     * Forks and creates a socket pair for communication between parent and child process
     *
     * @param  resource $socket
     * @return int PID if master or 0 if child
     *
     * @throws RuntimeException
     */
    private function fork(&$socket): int
    {
        $pair = [];

        $domain = (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX);
        if (\socket_create_pair($domain, SOCK_STREAM, 0, $pair) === false) {
            $error = socket_strerror(socket_last_error($pair[0] ?? null));

            $this->logger->error('{type}: unable to create socket pair; ' . $error, $this->logContext);
            exit(0);
        }

        // Fork child worker.
        $pid = pcntl_fork();
        if ($pid === -1) {
            throw new RuntimeException('Unable to fork child worker.');
        }

        if ($pid !== 0) {
            // MASTER
            $this->childProcesses++;

            $socket = $pair[0];
            socket_close($pair[1]);

            // wait up to 10ms to receive data
            socket_set_option(
                $socket,
                SOL_SOCKET,
                SO_RCVTIMEO,
                ['sec' => 0, 'usec' => 10000]
            );

            return $pid;
        }

        $socket = $pair[1];
        socket_close($pair[0]);

        register_shutdown_function($this->handleChildErrors($socket));

        return $pid;
    }

    /**
     * A shutdown function for the forked process.
     *
     * NOTE: Shutdown functions will not be executed if the process is killed with a SIGTERM or SIGKILL signal.
     *
     * @param  resource $socket
     * @return Closure
     */
    private function handleChildErrors(&$socket): Closure
    {
        // This storage is freed on error (case of allowed memory exhausted).
        $reserved = str_repeat('*', 32 * 1024);

        return function () use (&$reserved, &$socket): void {
            unset($reserved);

            $error = error_get_last();

            if ($error === null) {
                unset($reserved);
                return;
            }

            $handler = new ErrorFormatter();
            if ($handler->constant($error['type']) === null) {
                $this->logger->warning(
                    '{type}: Unable to recognize error type. Skip sending error to master: {message}',
                    $this->logContext + ['message' => $error['message']]
                );
                return;
            }

            if (is_resource($socket) == false) {
                $this->logger->warning(
                    '{type}: supplied resource is not a valid socket resource. Skip sending error to master: {message}',
                    $this->logContext + ['message' => $error['message']]
                );
                return;
            }

            $this->logger->debug('{type}: sending error to master', $this->logContext);
            $data = serialize($error);

            do {
                $len = socket_write($socket, $data);
                if ($len === false || $len === 0) {
                    break;
                }

                $data = substr($data, $len);
            } while (is_numeric($len) && $len > 0 && is_resource($socket));
        };
    }

    /**
     * Tries to create an error message from the socket.
     *
     * @param  resource $socket
     * @return null|string
     */
    private function readErrorFromSocket($socket): ?string
    {
        $error = '';
        while (!empty($res = socket_read($socket, 8192))) {
            $error .= $res;
        }

        $error = unserialize($error);

        if (is_array($error)) {
            $handler = new ErrorFormatter();

            return sprintf(
                '%s: %s in %s on line %d',
                $handler->constant($error['type']) ?: 'Unknown',
                $error['message'],
                $error['file'],
                $error['line']
            );
        }

        return null;
    }

    /**
     * Handle process exit status.
     *
     * @param int $pid
     * @param int $childType
     * @param int $exitStatus
     *
     * @return false|string FALSE if exit status indicates success; otherwise, a string containing the error messages.
     */
    private function handleProcessExitStatus(int $pid, int $childType, int $exitStatus)
    {
        switch ($childType) {
            case self::PROCESS_TYPE_JOB:
                $childType = 'Child';
                break;
            default:
                $childType = 'Watchdog';
        }

        if ($exitStatus === 0) {
            $this->logger->debug("{type}: {$childType} process exited successfully", $this->logContext);
            return false;
        }

        $error = $this->readErrorFromSocket($this->sockets[$pid]);
        $jobFailedMessage = $error ?: "{$childType} process failed with status: {$exitStatus}";

        $this->logger->error("{type}: fatal error in {$childType} process: {$jobFailedMessage}", $this->logContext);

        return $jobFailedMessage;
    }

    private function childStart(): void
    {
        $this->getEventsManager()->fire(new WorkerEvent\AfterFork($this));

        $socket = null;
        $this->childPID = $this->fork($socket);

        if ($this->childPID !== 0) {
            $this->sockets[$this->childPID] = $socket;
            return;
        }

        if ($this->job instanceof BaseJob == false) {
            /**
             * @todo
             * Something went strange.
             * I definitely should sort out with this.
             */
            return;
        }

        $this->getEventsManager()->fire(new WorkerEvent\AfterFork($this));

        $this->processType = self::PROCESS_TYPE_JOB;

        $jid = $this->job->jid;
        $this->who = 'child:' . $this->name;
        $this->logContext = ['type' => $this->who];

        $this->title('Processing ' . $jid . ' since ' . strftime('%F %T'));
        $this->childPerform($this->job);

        socket_close($socket);

        exit(0);
    }

    /**
     * Process a single job.
     *
     * @param  BaseJob $job The job to be processed.
     * @return void
     */
    public function childPerform(BaseJob $job): void
    {
        $loggerContext = ['job' => $job->jid, 'type' => $this->who];

        try {
            if ($this->jobPerformHandler) {
                if ($this->jobPerformHandler instanceof EventsManagerAwareInterface) {
                    $this->jobPerformHandler->setEventsManager($this->client->getEventsManager());
                }

                if (method_exists($this->jobPerformHandler, 'setUp')) {
                    $this->jobPerformHandler->setUp();
                }

                $this->getEventsManager()->fire(new JobEvent\BeforePerform($this->jobPerformHandler, $job));
                $this->jobPerformHandler->perform($job);
                $this->getEventsManager()->fire(new JobEvent\AfterPerform($this->jobPerformHandler, $job));

                if (method_exists($this->jobPerformHandler, 'tearDown')) {
                    $this->jobPerformHandler->tearDown();
                }
            } else {
                $job->perform();
            }

            $this->logger->notice('{type}: job {job} has finished', $loggerContext);
        } catch (\Throwable $e) {
            $loggerContext['stack'] = $e->getMessage();
            $this->logger->critical('{type}: job {job} has failed {stack}', $loggerContext);

            $job->fail(
                'system:fatal',
                sprintf('%s: %s in %s on line %d', get_class($e), $e->getMessage(), $e->getFile(), $e->getLine())
            );
        }
    }

    /**
     * @param  int $status The status parameter supplied to a successful call to pcntl_* functions.
     * @return bool
     */
    private function childProcessStatus(int $status): bool
    {
        if ($this->childPID == null) {
            return false;
        }

        switch (true) {
            case pcntl_wifexited($status):
                $code = pcntl_wexitstatus($status);
                $res = $this->handleProcessExitStatus($this->childPID, self::PROCESS_TYPE_JOB, $code);

                if ($res !== false && $this->job instanceof BaseJob) {
                    $this->job->fail('system:fatal', $res);
                }
                return true;
            case pcntl_wifsignaled($status):
                $sig = pcntl_wtermsig($status);
                if ($sig !== SIGKILL) {
                    $this->childProcessUnhandledSignal($sig);
                }
                return true;
            case pcntl_wifstopped($status):
                $sig = pcntl_wstopsig($status);
                $this->logger->info(
                    sprintf(
                        "child %d was stopped with signal %s\n",
                        $this->childPID,
                        SignalHandler::sigName($sig)
                    )
                );
                return false;
            default:
                $this->logger->error(sprintf("unexpected status for child %d; exiting\n", $this->childPID));
                exit(1);
        }
    }

    private function childProcessUnhandledSignal($sig)
    {
        $context = $this->logContext;
        $context['signal'] = SignalHandler::sigName($sig);
        $this->logger->notice("{type}: child terminated with unhandled signal '{signal}'", $context);
    }

    private function childKill()
    {
        if ($this->childPID === null) {
            return;
        }

        $this->logger->info('{type}: killing child at {child}', ['child' => $this->childPID, 'type' => $this->who]);

        if (pcntl_waitpid($this->childPID, $status, WNOHANG) != -1) {
            posix_kill($this->childPID, SIGKILL);
        }

        $this->childPID = null;
    }

    private function watchdogStart(WatchdogSubscriber $subscriber): void
    {
        $this->getEventsManager()->fire(new WorkerEvent\BeforeFork($this));

        $socket = null;
        $this->watchdogPID = $this->fork($socket);

        if ($this->watchdogPID !== 0) {
            // Mater process should get out of here
            $this->sockets[$this->watchdogPID] = $socket;
            return;
        }

        if ($this->job instanceof BaseJob == false) {
            /**
             * @todo
             * Something went strange.
             * I definitely should sort out with this.
             */
            return;
        }

        $this->getEventsManager()->fire(new WorkerEvent\AfterFork($this));

        $this->processType = self::PROCESS_TYPE_WATCHDOG;

        $this->who = 'watchdog:' . $this->name;
        $this->logContext = ['type' => $this->who];

        $this->title(sprintf('Watching events for %s since %s', $this->job->jid, strftime('%F %T')));
        $subscriber->watchdog($this->job->jid, $this->name, $this->childPID);

        socket_close($socket);
        $this->logger->info("{type}: done", $this->logContext);

        exit(0);
    }

    /**
     * @param  int $status The status parameter supplied to a successful call to pcntl_* functions.
     * @return bool
     */
    private function watchdogProcessStatus(int $status): bool
    {
        if ($this->watchdogPID === null) {
            return false;
        }

        switch (true) {
            case pcntl_wifexited($status):
                $code = pcntl_wexitstatus($status);
                $this->handleProcessExitStatus($this->watchdogPID, self::PROCESS_TYPE_WATCHDOG, $code);
                return true;
            case pcntl_wifsignaled($status):
                $sig = pcntl_wtermsig($status);
                if ($sig !== SIGKILL) {
                    $this->logger->warning(
                        sprintf(
                            "watchdog %d terminated with unhandled signal %s\n",
                            $this->watchdogPID,
                            SignalHandler::sigName($sig)
                        )
                    );
                }
                return true;
            case pcntl_wifstopped($status):
                $sig = pcntl_wstopsig($status);
                $this->logger->warning(
                    sprintf(
                        "watchdog %d was stopped with signal %s\n",
                        $this->watchdogPID,
                        SignalHandler::sigName($sig)
                    )
                );
                return false;
            default:
                $this->logger->error(sprintf("unexpected status for watchdog %d; exiting\n", $this->childPID));
                exit(1);
        }
    }

    private function watchdogKill()
    {
        if ($this->watchdogPID) {
            $this->logger->info(
                '{type}: Killing watchdog at {child}',
                ['child' => $this->watchdogPID, 'type' => $this->who]
            );

            if (pcntl_waitpid($this->watchdogPID, $status, WNOHANG) != -1) {
                posix_kill($this->watchdogPID, SIGKILL);
            }
            $this->watchdogPID = null;
        }
    }

    /**
     * {@inheritdoc}
     *
     * @return void
     */
    public function shutdown(): void
    {
        if ($this->childPID) {
            $message = '{type}: QUIT received; shutting down after child completes work';
        } else {
            $message = '{type}: QUIT received; shutting down';
        }

        $this->logger->notice($message, ['type' => $this->name]);
        $this->doShutdown();
    }

    /**
     * {@inheritdoc}
     *
     * @return void
     */
    public function killChildren(): void
    {
        if (!$this->childPID && !$this->watchdogPID) {
            return;
        }

        $this->childKill();
        $this->watchdogKill();
    }
}


<?php

namespace Qless;

use Qless\Exceptions\ExceptionFactory;
use Qless\Exceptions\QlessException;
use Qless\Exceptions\RuntimeException;
use Predis\Client as Redis;

/**
 * Qless\LuaScript
 *
 * Wrapper to load and execute Lua script for qless-core.
 * This is a class for internal usage only. You should not rely on its API
 * and to communicate with Lua you have to use Client class.
 *
 * @package Qless
 */
class LuaScript
{
    /** @var Redis */
    private $redis;

    /** @var ?string */
    private $sha;

    /** @var string */
    private $corePath;

    /**
     * Lua constructor.
     *
     * @param Redis $redis
     */
    public function __construct(Redis $redis)
    {
        $this->redis = $redis;
        $this->corePath = __DIR__ . '/qless-core/qless.lua';
    }

    /**
     * Run a Lua command serverside.
     *
     * @param  string $command
     * @param  array  $args
     * @return mixed|null
     *
     * @throws RuntimeException
     * @throws QlessException
     */
    public function run(string $command, array $args)
    {
        if (empty($this->sha)) {
            $this->reload();
        }

        $arguments = $this->normalizeCommandArgs($command, $args);

        try {
            return call_user_func_array([$this->redis, 'evalsha'], $arguments);

        } catch (\Exception $exception) {
            throw ExceptionFactory::fromErrorMessage($exception->getMessage());
        }
    }

    /**
     * Prepares arguments to call the specified Lua command.
     *
     * @param  string $command
     * @param  array  $args
     * @return array
     */
    private function normalizeCommandArgs(string $command, array $args): array
    {
        $arguments = array_merge([$command, microtime(true)], $args);

        array_unshift($arguments, 0);
        array_unshift($arguments, $this->sha);

        return $arguments;
    }

    /**
     * Reloads the qless-core code.
     *
     * @return void
     *
     * @throws RuntimeException
     */
    private function reload(): void
    {
        $this->sha = (string) @sha1_file($this->corePath);

        if (empty($this->sha)) {
            throw new RuntimeException(
                'Unable to locate qless-core file at path: ' . $this->corePath
            );
        }

        $res = $this->redis->script('exists', $this->sha);

        if ($res[0] !== 1) {
            $this->sha = $this->redis->script('load', file_get_contents($this->corePath));
        }
    }
}


<?php

namespace Qless;

/**
 * Qless\EventsManagerAwareTrait
 *
 * @package Qless
 */
trait EventsManagerAwareTrait
{
    /** @var EventsManager */
    private $eventsManager;

    /**
     * {@inheritdoc}
     *
     * @return EventsManager
     */
    final public function getEventsManager(): EventsManager
    {
        if ($this->eventsManager === null) {
            $this->eventsManager = new EventsManager();
        }

        return $this->eventsManager;
    }

    /**
     * {@inheritdoc}
     *
     * @param  EventsManager $manager
     * @return void
     */
    final public function setEventsManager(EventsManager $manager): void
    {
        $this->eventsManager = $manager;
    }
}


<?php

namespace Qless\Jobs;

use Qless\Client;
use Qless\Events\User\Job as JobEvent;
use Qless\Exceptions\InvalidArgumentException;
use Qless\Exceptions\JobAlreadyFinishedException;
use Qless\Exceptions\LostLockException;
use Qless\Exceptions\QlessException;
use Qless\Exceptions\RuntimeException;

/**
 * Qless\Jobs\BaseJob
 *
 * @package Qless\Jobs
 *
 * @property-read array $history
 * @property-read string[] $dependencies
 * @property-read string[] $dependents
 * @property-read string $worker
 * @property-read float $expires
 * @property-read int $remaining
 * @property-read string $description
 * @property-read bool $tracked
 * @property-read bool $failed
 * @property-read bool $completed
 */
class BaseJob extends AbstractJob implements \ArrayAccess
{
    private const STATE_FAILED = 'failed';

    private const STATE_COMPLETED = 'completed';

    /**
     * The history of what has happened to the job so far.
     *
     * @var array
     */
    private $history;

    /**
     * The jids of other jobs that must complete before this one.
     *
     * @var string[]
     */
    private $dependencies;

    /**
     * The jids of other jobs that depend on this one.
     *
     * @var string[]
     */
    private $dependents;

    /**
     * The internal worker name (usually consumer identifier).
     *
     * @var string
     */
    private $worker;

    /**
     * When you must either check in with a heartbeat or turn it in as completed.
     *
     * @var float
     */
    private $expires;

    /**
     * The number of retries remaining for this job.
     *
     * @var int
     */
    private $remaining;

    /**
     * Is current job tracked.
     *
     * @var bool
     */
    private $tracked = false;

    /**
     * Is current job failed
     *
     * @var bool
     */
    private $failed;

    /**
     * Is current job completed
     *
     * @var bool
     */
    private $completed;

    /** @var ?object */
    private $instance;

    /**
     * Job constructor.
     *
     * @param Client $client
     * @param array $data
     */
    public function __construct(Client $client, array $data)
    {
        parent::__construct($client, $data['jid'], $data);

        $this->history = $data['history'] ?? [];
        $this->dependencies = $data['dependencies'] ?? [];
        $this->dependents = $data['dependents'] ?? [];
        $this->worker = $data['worker'];
        $this->expires = (float) ($data['expires'] ?? 0.0);
        $this->remaining = (int) $data['remaining'] ?? 0;
        $this->tracked = (bool) $data['tracked'] ?? false;
        $this->failed = $data['state'] === self::STATE_FAILED;
        $this->completed = $data['state'] === self::STATE_COMPLETED;
    }

    /**
     * Get the history of what has happened to the job so far.
     *
     * @return array
     */
    public function getHistory(): array
    {
        return $this->history;
    }

    /**
     * Get the jids of other jobs that must complete before this one.
     *
     * @return string[]
     */
    public function getDependencies(): array
    {
        return $this->dependencies;
    }

    /**
     * Get the jids of other jobs that depend on this one.
     *
     * @return string[]
     */
    public function getDependents(): array
    {
        return $this->dependents;
    }

    /**
     * Get the internal worker name (usually consumer identifier).
     *
     * @return string
     */
    public function getWorker(): string
    {
        return $this->worker;
    }

    /**
     * Get when you must either check in with a heartbeat or turn it in as completed.
     *
     * @return float
     */
    public function getExpires(): float
    {
        return $this->expires;
    }

    /**
     * Get the number of retries remaining for this job.
     *
     * @return int
     */
    public function getRemaining(): int
    {
        return $this->remaining;
    }

    /**
     * Is current job tracked.
     *
     * @return bool
     */
    public function getTracked(): bool
    {
        return $this->tracked;
    }

    /**
     * Is current job failed.
     *
     * @return bool
     */
    public function getFailed(): bool
    {
        return $this->failed;
    }

    /**
     * Is current job completed.
     *
     * @return bool
     */
    public function getCompleted(): bool
    {
        return $this->completed;
    }

    /**
     * Gets Job's description.
     *
     * @return string
     */
    public function getDescription(): string
    {
        return sprintf('%s %s / %s', $this->getKlass(), $this->getJid(), $this->getQueue());
    }

    /**
     * Sets Job's priority.
     *
     * @param  int $priority
     * @return void
     *
     * @throws QlessException
     * @throws RuntimeException
     */
    public function setPriority(int $priority): void
    {
        if ($this->client->call('priority', $this->jid, $priority)) {
            parent::setPriority($priority);
        }
    }

    /**
     * Cancel a job.
     *
     * It will be deleted from the system, the thinking being that if you don't want
     * to do any work on it, it shouldn't be in the queuing system. Optionally cancels all jobs's dependents.
     *
     * @param  bool $dependents true if associated dependents should also be cancelled
     * @return array
     */
    public function cancel($dependents = false): array
    {
        if ($dependents && !empty($this->rawData['dependents'])) {
            return call_user_func_array(
                [$this->client, 'cancel'],
                array_merge([$this->jid], $this->rawData['dependents'])
            );
        }

        return $this->client->cancel($this->jid);
    }

    /**
     * Seconds remaining before this job will timeout.
     *
     * @return float
     */
    public function ttl(): float
    {
        return $this->expires - microtime(true);
    }

    /**
     * Complete a job and optionally put it in another queue,
     * either scheduled or to be considered waiting immediately.
     *
     * Like Queue::put and Queue::move, it accepts a delay, and dependencies.
     *
     * @see \Qless\Queues\Queue::put
     *
     * @param  string|null $nextq
     * @param  int         $delay
     * @param  array       $depends
     * @return string
     */
    public function complete(?string $nextq = null, int $delay = 0, array $depends = []): string
    {
        if ($this->completed || $this->failed) {
            throw new JobAlreadyFinishedException();
        }

        $params = [
            $this->jid,
            $this->worker,
            $this->queue,
            json_encode($this->data, JSON_UNESCAPED_SLASHES) ?: '{}'
        ];

        if ($nextq) {
            $next = ['next', $nextq, 'delay', $delay, 'depends', json_encode($depends, JSON_UNESCAPED_SLASHES)];
            $params = array_merge($params, $next);
        }

        $this->completed = true;

        return call_user_func_array(
            [$this->client, 'complete'],
            $params
        );
    }

    /**
     * Requeue this job.
     *
     * Optional values to replace when re-queuing job
     *
     * * int delay          delay (in seconds)
     * * array data         replacement data
     * * int priority       replacement priority
     * * int retries        replacement number of retries
     * * string[] tags      replacement tags
     * * string[] depends   replacement list of JIDs this job is dependent on
     *
     * @param  string $queue New queue name.
     * @param  array  $opts  Optional parameters.
     * @return string
     */
    public function requeue(?string $queue = null, array $opts = []): string
    {
        $opts = array_merge(
            [
                'delay'     => 0,
                'data'      => $this->data,
                'priority'  => $this->priority,
                'retries'   => $this->retries,
                'tags'      => $this->tags,
                'depends'   => $this->dependencies,
            ],
            $opts
        );

        $queueName = $queue ?: $this->queue;

        $data = json_encode($opts['data'], JSON_UNESCAPED_SLASHES) ?: '{}';

        return $this->client
            ->requeue(
                $this->worker,
                $queueName,
                $this->jid,
                $this->klass,
                $data,
                $opts['delay'],
                'priority',
                $opts['priority'],
                'tags',
                json_encode($opts['tags'], JSON_UNESCAPED_SLASHES),
                'retries',
                $opts['retries'],
                'depends',
                json_encode($opts['depends'], JSON_UNESCAPED_SLASHES)
            );
    }

    /**
     * Return the job to the work queue for processing
     *
     * @param string $group
     * @param string $message
     * @param int $delay
     *
     * @return int remaining retries available
     */
    public function retry($group, $message, $delay = 0)
    {
        return $this->client
            ->retry(
                $this->jid,
                $this->queue,
                $this->worker,
                $delay,
                $group,
                $message
            );
    }

    /**
     * Set the timestamp of the new heartbeat.
     *
     * @param  array $data
     * @return float
     *
     * @throws LostLockException
     */
    public function heartbeat(array $data = []): float
    {
        try {
            $this->expires = $this->client->heartbeat(
                $this->jid,
                $this->worker,
                json_encode($data, JSON_UNESCAPED_SLASHES)
            );
        } catch (QlessException $e) {
            throw new LostLockException($e->getMessage(), 'Heartbeat', $this->jid, $e->getCode(), $e);
        }

        return $this->expires;
    }

    /**
     * Creates the instance to perform the job and calls the method on the instance.
     *
     * The instance must be specified in the payload['performMethod'];
     *
     * @return bool
     */
    public function perform(): bool
    {
        try {
            $instance = $this->getInstance();

            if (method_exists($instance, 'setUp')) {
                $instance->setUp();
            }

            $this->getEventsManager()->fire(new JobEvent\BeforePerform($this, $this));
            $performMethod = $this->getPerformMethod();
            $instance->$performMethod($this);
            $this->getEventsManager()->fire(new JobEvent\AfterPerform($this, $this));

            if (method_exists($instance, 'tearDown')) {
                $instance->tearDown();
            }
        } catch (\Throwable $e) {
            $this->fail(
                'system:fatal',
                sprintf('%s: %s in %s on line %d', get_class($e), $e->getMessage(), $e->getFile(), $e->getLine())
            );

            return false;
        }

        return true;
    }

    /**
     * Mark the current Job as failed, with the provided group, and a more specific message.
     *
     * @param string $group   Some phrase that might be one of several categorical modes of failure
     * @param string $message Something more job-specific, like perhaps a traceback.
     *
     * @return bool|string The id of the failed Job if successful, or FALSE on failure.
     */
    public function fail(string $group, string $message)
    {
        if ($this->completed || $this->failed) {
            throw new JobAlreadyFinishedException();
        }

        $jsonData = json_encode($this->data, JSON_UNESCAPED_SLASHES) ?: '{}';

        $this->getEventsManager()->fire(new JobEvent\OnFailure($this, $this, $group, $message));

        $this->failed = true;

        return $this->client->fail($this->jid, $this->worker, $group, $message, $jsonData);
    }

    /**
     * Timeout this Job.
     *
     * @return void
     */
    public function timeout(): void
    {
        $this->client->timeout($this->jid);
    }

    /**
     * Start tracking current job.
     *
     * @return void
     */
    public function track(): void
    {
        if ($this->client->call('track', 'track', $this->jid)) {
            $this->tracked = true;
        }
    }

    /**
     * Stop tracking current job.
     *
     * @return void
     */
    public function untrack(): void
    {
        if ($this->client->call('track', 'untrack', $this->jid)) {
            $this->tracked = false;
        }
    }

    /**
     * Get the instance of the class specified on this job.
     *
     * This instance will be used to call a perform method:
     * - $payload['performMethod']
     * - "perform" if not specified
     *
     * @return object
     *
     * @throws InvalidArgumentException
     */
    public function getInstance()
    {
        if ($this->instance === null) {
            $this->instance = $this->jobFactory->create(
                $this->klass,
                $this->getPerformMethod()
            );
        }

        return $this->instance;
    }

    /**
     * Gets method to execute on the instance (defaults to "perform").
     *
     * @return string
     */
    protected function getPerformMethod(): string
    {
        return $this->data['performMethod'] ?? 'perform';
    }

    /**
     * String representation of the job.
     *
     * @return string
     */
    public function __toString()
    {
        return sprintf('%s %s', get_class($this), $this->description);
    }

    public function offsetExists($offset)
    {
        return isset($this->data[$offset]);
    }

    public function offsetGet($offset)
    {
        return $this->data[$offset];
    }

    public function offsetSet($offset, $value)
    {
        $this->data[$offset] = $value;
    }

    public function offsetUnset($offset)
    {
        unset($this->data[$offset]);
    }
}


<?php

namespace Qless\Jobs\Reservers;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Qless\Exceptions\InvalidArgumentException;
use Qless\Jobs\BaseJob;
use Qless\Queues\Collection;
use Qless\Queues\Queue;

/**
 * Qless\Jobs\Reservers\AbstractReserver
 *
 * Abstract Job reserver. Different reservers use different
 * strategies for which order jobs are popped off of queues.
 *
 * @package Qless\Jobs\Reservers
 */
abstract class AbstractReserver implements ReserverInterface
{
    /** @var Queue[] */
    protected $queues = [];

    /** @var string|null */
    protected $worker;

    /**
     * Current reserver type description.
     *
     * @var string|null
     */
    protected $description;

    /**
     * Logging object that implements the PSR-3 LoggerInterface.
     *
     * @var LoggerInterface
     */
    protected $logger;

    /**
     * Whether the reserver should refresh the list of queues, or just go with the queues it has been given.
     *
     * @var bool
     */
    protected $refreshQueues = false;

    /**
     * A specification to reserve queues.
     *
     * @var string|null
     */
    protected $spec;

    /**
     * The queue collection.
     *
     * @var Collection
     */
    protected $collection;

    /**
     * Current reserver type description.
     *
     * @var string
     */
    protected const TYPE_DESCRIPTION = 'undefined';

    /**
     * Instantiate a new reserver, given a list of queues that it should be working on.
     *
     * @param  Collection  $collection
     * @param  array|null  $queues
     * @param  string|null $spec
     * @param  string|null $worker
     *
     * @throws InvalidArgumentException
     */
    public function __construct(
        Collection $collection,
        $queues = null,
        ?string $spec = null,
        ?string $worker = null
    ) {
        if (empty($queues) == true && empty($spec) == true) {
            throw new InvalidArgumentException(
                'A queues list or a specification to reserve queues are required.'
            );
        }

        // Get the queues to reserve.
        if (empty($queues) == false) {
            $queues = is_array($queues) ? $queues : [$queues];
            $this->queues = array_map(function (string $name) use ($collection): Queue {
                return $collection[trim($name)];
            }, $queues);
        } else {
            $this->spec = $spec;
            $this->refreshQueues = true;
            $this->queues = $collection->fromSpec($spec);
        }

        $this->collection = $collection;
        $this->worker = $worker;
        $this->logger = new NullLogger();
    }

    /**
     * {@inheritdoc}
     *
     * @return Queue[]
     */
    public function getQueues(): array
    {
        return $this->queues;
    }

    /**
     * {@inheritdoc}
     *
     * @return void
     */
    public function beforeWork(): void
    {
        if ($this->refreshQueues && empty($this->spec) == false) {
            $this->queues = $this->collection->fromSpec($this->spec);

            if (empty($this->queues) == true) {
                $this->logger->info('Refreshing queues dynamically, but there are no queues yet');
            }
        }
    }

    /**
     * {@inheritdoc}
     *
     * @return string
     */
    public function getDescription(): string
    {
        if ($this->description === null) {
            $this->description = $this->initializeDescription($this->queues);
        }

        return $this->description;
    }

    /**
     * {@inheritdoc}
     *
     * @param  LoggerInterface $logger
     * @return void
     */
    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * {@inheritdoc}
     *
     * @return BaseJob|null
     */
    public function reserve(): ?BaseJob
    {
        $this->beforeWork();

        $this->logger->debug('Attempting to reserve a job using {reserver} reserver', [
            'reserver' => $this->getDescription(),
        ]);

        foreach ($this->queues as $queue) {
            /** @var \Qless\Jobs\BaseJob|null $job */
            $job = $queue->pop($this->worker);
            if ($job !== null) {
                $this->logger->info('Found a job on {queue}', ['queue' => (string) $queue]);
                return $job;
            }
        }

        return null;
    }

    protected function initializeDescription(array $queues): string
    {
        $names = array_map(function (Queue $queue) {
            return (string) $queue;
        }, $queues);

        return  trim(implode(', ', $names) . ' (' . static::TYPE_DESCRIPTION . ')');
    }

    protected function resetDescription(): void
    {
        $this->description = null;
    }
}


<?php

namespace Qless\Jobs\Reservers;

use Qless\Queues\Queue;

/**
 * Class PriorityReserver
 * @package Qless\Jobs\Reservers
 */
class PriorityReserver extends AbstractReserver implements ReserverInterface
{
    /** @var array */
    private $priorities = [];

    /**
     * {@inheritdoc}
     *
     * @var string
     */
    const TYPE_DESCRIPTION = 'priority';

    /**
     * Default priority of queue
     */
    const DEFAULT_PRIORITY = 5;

    /**
     * @param array $priorities
     */
    public function setPriorities(array $priorities): void
    {
        $this->priorities = $priorities;
    }

    /**
     * {@inheritdoc}
     *
     * @return void
     */
    public function beforeWork(): void
    {
        parent::beforeWork();

        // Random for queues with equal priorities
        shuffle($this->queues);

        $priorities = $this->priorities;
        usort($this->queues, function (Queue $firstQueue, Queue $secondQueue) use ($priorities) {
            $priorityFirst = $priorities[(string) $firstQueue] ?? self::DEFAULT_PRIORITY;
            $prioritySecond = $priorities[(string) $secondQueue] ?? self::DEFAULT_PRIORITY;

            return $prioritySecond - $priorityFirst;
        });

        if (empty($this->queues) == false) {
            $this->logger->info(
                'Monitoring the following queues: {queues}',
                ['queues' => implode(', ', $this->queues)]
            );
        }
    }
}


<?php

namespace Qless\Jobs;

use Qless\Client;
use Qless\EventsManagerAwareInterface;
use Qless\EventsManagerAwareTrait;
use Qless\Exceptions\InvalidArgumentException;
use Qless\Support\PropertyAccessor;

/**
 * Qless\Jobs\AbstractJob
 *
 * The base class for both BaseJob and RecurringJob.
 *
 * @property-read string $klass
 * @property-read string $queue
 * @property-read string $jid
 * @property int $priority
 * @property-read string[] $tags
 * @property-read int $retries
 * @property JobData $data
 *
 * @package Qless\Jobs
 */
abstract class AbstractJob implements EventsManagerAwareInterface
{
    use EventsManagerAwareTrait, PropertyAccessor;

    /**
     * The job id.
     *
     * @var string
     */
    private $jid = '';

    /**
     * The class of the job.
     *
     * @var string
     */
    private $klass = '';

    /**
     * The queue the job is in.
     *
     * @var string
     */
    private $queue = '';

    /**
     * Array of tags for this job.
     *
     * @var string[]
     */
    private $tags = [];

    /**
     * The priority of this job.
     *
     * var int
     */
    private $priority = 0;

    /**
     * The number of retries originally requested.
     *
     * @var int
     */
    private $retries = 0;

    /**
     * The data for the job.
     *
     * @var JobData
     */
    private $data;

    /** @var array */
    protected $rawData = [];

    /** @var JobFactory */
    protected $jobFactory;

    /** @var Client */
    protected $client;

    /**
     * Job constructor.
     *
     * @param Client $client
     * @param string $jid
     * @param array  $data
     */
    public function __construct(Client $client, string $jid, array $data)
    {
        $this->jobFactory = new JobFactory();
        $this->jobFactory->setEventsManager($client->getEventsManager());

        $this->client = $client;
        $this->jid = $jid;
        $this->rawData = $data;

        $this->setKlass($data['klass']);
        $this->setQueue($data['queue']);

        $this->setRetries((int) $data['retries'] ?? 0);
        $this->setPriority((int) $data['priority'] ?? 0);
        $this->setTags($data['tags'] ?? []);

        $this->setData(new JobData(json_decode($data['data'], true) ?: []));
    }

    /**
     * Gets Job's ID.
     *
     * @return string
     */
    public function getJid(): string
    {
        return $this->jid;
    }

    /**
     * Gets Job's klass.
     *
     * @return string
     */
    public function getKlass(): string
    {
        return $this->klass;
    }

    /**
     * Gets Job's queue.
     *
     * @return string
     */
    public function getQueue(): string
    {
        return $this->queue;
    }

    /**
     * Gets Job's tags.
     *
     * @return string[]
     */
    public function getTags(): array
    {
        return $this->tags;
    }

    /**
     * Gets Job's priority.
     *
     * @return int
     */
    public function getPriority(): int
    {
        return $this->priority;
    }

    /**
     * Gets Job's retries.
     *
     * @return int
     */
    public function getRetries(): int
    {
        return $this->retries;
    }

    /**
     * Gets Job's data.
     *
     * @return JobData
     */
    public function getData(): JobData
    {
        return $this->data;
    }

    /**
     * @return array
     */
    public function getFailure(): array
    {
        return $this->rawData['failure'] ?? [];
    }

    /**
     * @return string
     */
    public function getState(): string
    {
        return $this->rawData['state'] ?? '';
    }

    /**
     * Add the specified tags to this job.
     *
     * @param  string ...$tags A list of tags to to add to this job.
     * @return void
     */
    public function tag(...$tags): void
    {
        $response = call_user_func_array(
            [$this->client, 'call'],
            array_merge(['tag', 'add', $this->jid], array_values(func_get_args()))
        );

        $this->setTags(json_decode($response, true));
    }

    /**
     * Remove the specified tags to this job.
     *
     * @param  string ...$tags A list of tags to remove from this job.
     * @return void
     */
    public function untag(...$tags): void
    {
        $response = call_user_func_array(
            [$this->client, 'call'],
            array_merge(['tag', 'remove', $this->jid], array_values(func_get_args()))
        );

        $this->setTags(json_decode($response, true));
    }

    /**
     * Sets Job's data.
     *
     * @param  JobData|array|string $data
     * @return void
     *
     * @throws InvalidArgumentException
     */
    public function setData($data): void
    {
        if (is_array($data) == false && is_string($data) == false && $data instanceof JobData == false) {
            throw new InvalidArgumentException(
                sprintf(
                    "Job's data must be either an array, or a JobData instance, or a JSON string, %s given.",
                    gettype($data)
                )
            );
        }

        if (is_array($data) == true) {
            $data =  new JobData($data);
        } elseif (is_string($data) == true) {
            // Assume this is JSON
            $data =  new JobData(json_decode($data, true));
        }

        $this->data = $data;
    }

    /**
     * Sets Job's priority.
     *
     * @param  int $priority
     * @return void
     */
    public function setPriority(int $priority): void
    {
        $this->priority = $priority;
    }

    /**
     * Sets Job's retries.
     *
     * @param  int $retries
     * @return void
     */
    public function setRetries(int $retries): void
    {
        $this->retries = $retries;
    }

    /**
     * Sets Job's klass.
     *
     * @param  string $className
     * @return void
     */
    public function setKlass(string $className): void
    {
        $this->klass = $className;
    }

    /**
     * Sets Job's queue name.
     *
     * @param  string $queue
     * @return void
     */
    public function setQueue(string $queue): void
    {
        $this->queue = $queue;
    }

    /**
     * Sets Job's tags.
     *
     * @param  array $tags
     * @return void
     */
    public function setTags(array $tags): void
    {
        $this->tags = $tags;
    }
}


<?php

namespace Qless\Jobs;

use Qless\Client;
use Qless\Exceptions\InvalidArgumentException;
use Qless\Exceptions\QlessException;
use Qless\Exceptions\RuntimeException;

/**
 * Qless\Jobs\RecurringJob
 *
 * Wraps a recurring job.
 *
 * @property int $interval
 * @property-read int $count
 * @property int $backlog
 * @property int $retries
 * @property string $klass
 *
 * @package Qless\Jobs
 */
class RecurringJob extends AbstractJob
{
    /** @var int  */
    private $interval = 60;

    /** @var int  */
    private $count = 0;

    /** @var int  */
    private $backlog = 0;

    /**
     * Job constructor.
     *
     * @param Client $client
     * @param array $data
     */
    public function __construct(Client $client, array $data)
    {
        parent::__construct($client, $data['jid'], $data);

        $this->interval = (int) $data['interval'] ?? 60;
        $this->count = (int) $data['count'] ?? 0;
        $this->backlog = (int) $data['backlog'] ?? 0;
    }

    /**
     * Gets Job's interval.
     *
     * @return int
     */
    public function getInterval(): int
    {
        return $this->interval;
    }

    /**
     * Gets Job's count.
     *
     * @return int
     */
    public function getCount(): int
    {
        return $this->count;
    }

    /**
     * Gets Job's backlog.
     *
     * @return int
     */
    public function getBacklog(): int
    {
        return $this->backlog;
    }

    /**
     * {@inheritdoc}
     *
     * @param  int $retries
     * @return void
     *
     * @throws QlessException
     * @throws RuntimeException
     */
    public function setRetries(int $retries): void
    {
        if ($this->client->call('recur.update', $this->jid, 'retries', $retries)) {
            parent::setRetries($retries);
        }
    }

    /**
     * Sets Job's interval.
     *
     * @param  int $interval
     * @return void
     *
     * @throws QlessException
     * @throws RuntimeException
     */
    public function setInterval(int $interval): void
    {
        if ($this->client->call('recur.update', $this->jid, 'interval', $interval)) {
            $this->interval = $interval;
        }
    }

    /**
     * {@inheritdoc}
     *
     * @param  JobData|array|string $data
     * @return void
     *
     * @throws InvalidArgumentException
     * @throws QlessException
     * @throws RuntimeException
     */
    public function setData($data): void
    {
        if (is_array($data) || $data instanceof JobData) {
            $update = json_encode($data, JSON_UNESCAPED_SLASHES);
        } elseif (is_string($data)) {
            // Assume this is JSON
            $update = $data;
        } else {
            throw new InvalidArgumentException(
                sprintf(
                    "Job's data must be either an array, or a JobData instance, or a JSON string, %s given.",
                    gettype($data)
                )
            );
        }

        if ($this->client->call('recur.update', $this->jid, 'data', $update)) {
            if ($data instanceof JobData) {
                parent::setData($data);
            } elseif (is_array($data)) {
                parent::setData(new JobData($data));
            } else {
                parent::setData(new JobData(json_decode($data, true) ?: []));
            }
        }
    }

    /**
     * {@inheritdoc}
     *
     * @param  string $className
     * @return void
     *
     * @throws QlessException
     * @throws RuntimeException
     */
    public function setKlass(string $className): void
    {
        if ($this->client->call('recur.update', $this->jid, 'klass', $className)) {
            parent::setKlass($className);
        }
    }

    /**
     * Sets Job's backlog.
     *
     * @param  int $backlog
     * @return void
     *
     * @throws QlessException
     * @throws RuntimeException
     */
    public function setBacklog(int $backlog): void
    {
        if ($this->client->call('recur.update', $this->jid, 'backlog', $backlog)) {
            $this->backlog = $backlog;
        }
    }

    /**
     * {@inheritdoc}
     *
     * @param  int $priority
     * @return void
     *
     * @throws QlessException
     * @throws RuntimeException
     */
    public function setPriority(int $priority): void
    {
        if ($this->client->call('recur.update', $this->jid, 'priority', $priority)) {
            parent::setPriority($priority);
        }
    }

    /**
     * Sets Job's queue name.
     *
     * @param  string $queue
     * @return void
     */
    public function requeue(string $queue): void
    {
        if ($this->client->call('recur.update', $this->jid, 'queue', $queue)) {
            $this->setQueue($queue);
        }
    }

    /**
     * Cancel a job.
     *
     * @return int
     */
    public function cancel(): int
    {
        return $this->client->call('unrecur', $this->jid);
    }

    /**
     * {@inheritdoc}
     *
     * @param  string ...$tags A list of tags to to add to this job.
     * @return void
     */
    public function tag(...$tags): void
    {
        call_user_func_array(
            [$this->client, 'call'],
            array_merge(['recur.tag', $this->jid], array_values(func_get_args()))
        );
    }

    /**
     * {@inheritdoc}
     *
     * @param  string ...$tags A list of tags to remove from this job.
     * @return void
     */
    public function untag(...$tags): void
    {
        call_user_func_array(
            [$this->client, 'call'],
            array_merge(['recur.untag', $this->jid], array_values(func_get_args()))
        );
    }
}


<?php

namespace Qless\Subscribers;

use Psr\Log\LoggerInterface;
use Qless\Events\User\Worker as WorkerEvent;
use Qless\Signals\SignalHandler;
use Qless\Workers\WorkerInterface;

/**
 * Qless\Subscribers\SignalsAwareSubscriber
 *
 * @package Qless\Subscribers
 */
class SignalsAwareSubscriber
{
    /** @var LoggerInterface */
    private $logger;

    /**
     * SignalsAwareSubscriber constructor.
     *
     * @param LoggerInterface $logger
     */
    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    public function setLogger(LoggerInterface $logger): void
    {
        $this->logger = $logger;
    }

    public function beforeFirstFork(WorkerEvent\BeforeFirstWork $event): void
    {
        /**
         * Do not use declare(ticks=1) instead use pcntl_async_signals(true)
         * There's no performance hit or overhead with `pcntl_async_signals()`.
         *
         * @link https://blog.pascal-martin.fr/post/php71-en-other-new-things.html
         */
        pcntl_async_signals(true);

        $this->registerSignalHandler($event->getSource());
    }

    public function afterFork(WorkerEvent\AfterFork $event): void
    {
        $this->clearSignalHandler();
    }

    /**
     * Register a signal handler.
     *
     * TERM: Shutdown immediately and stop processing jobs (quick shutdown).
     * INT:  Shutdown immediately and stop processing jobs (quick shutdown).
     * QUIT: Shutdown after the current job finishes processing (graceful shutdown).
     * USR1: Kill the forked child immediately and continue processing jobs.
     * USR2: Pausing job processing.
     * CONT: Resumes worker allowing it to pick.
     *
     * @link   http://man7.org/linux/man-pages/man7/signal.7.html
     *
     * @param  WorkerInterface $worker
     * @return void
     */
    protected function registerSignalHandler(WorkerInterface $worker): void
    {
        $this->logger->info('Register a signal handler that a worker should respond to');

        SignalHandler::create(
            SignalHandler::KNOWN_SIGNALS,
            function (int $signal, string $signalName) use ($worker) {
                $this->logger->info('{type}: Was received recognized signal {signal}', [
                    'type'   => $worker->getName(),
                    'signal' => $signalName,
                ]);

                switch ($signal) {
                    case SIGTERM:
                        $worker->shutDownNow();
                        break;
                    case SIGINT:
                        $worker->shutDownNow();
                        break;
                    case SIGQUIT:
                        $worker->shutdown();
                        break;
                    case SIGUSR1:
                        $worker->killChildren();
                        break;
                    case SIGUSR2:
                        $worker->pauseProcessing();
                        break;
                    case SIGCONT:
                        $worker->unPauseProcessing();
                        break;
                }
            }
        );
    }

    /**
     * Clear all previously registered signal handlers.
     *
     * @return void
     */
    protected function clearSignalHandler(): void
    {
        SignalHandler::unregister();
    }
}


<?php

namespace Qless\Subscribers;

use Predis\Client as Redis;
use Predis\PubSub\Consumer;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Qless\SystemFacade;

/**
 * Qless\Subscribers\WatchdogSubscriber
 *
 * A class used for subscribing to messages in a thread.
 *
 * @package Qless\Events
 */
class WatchdogSubscriber implements LoggerAwareInterface
{
    /** @var Redis */
    private $redis;

    /** @var array */
    private $channels = [];

    /** @var LoggerInterface */
    private $logger;

    /** @var mixed */
    private $defaultSocketTimeout;

    /** @var SystemFacade */
    private $system;

    private const LOCK_LOST = 'lock_lost';
    private const CANCELED = 'canceled';
    private const COMPLETED = 'completed';
    private const FAILED = 'failed';

    private const UNLIMITED = -1;

    private const WATCHDOG_EVENTS = [
        self::LOCK_LOST,
        self::CANCELED,
        self::COMPLETED,
        self::FAILED,
    ];

    /**
     * Subscriber constructor.
     *
     * @param Redis             $redis
     * @param array             $channels
     * @param SystemFacade|null $system
     */
    public function __construct(Redis $redis, array $channels, SystemFacade $system = null)
    {
        $this->redis = $redis;
        $this->channels = $channels;
        $this->logger = new NullLogger();
        $this->system = $system ?: new SystemFacade();

        $this->defaultSocketTimeout = ini_get('default_socket_timeout');
    }

    /**
     * {@inheritdoc}
     *
     * @param  LoggerInterface $logger
     * @return void
     */
    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * Watching events for a job.
     *
     * @param  string   $jid
     * @param  string   $worker
     * @param  int|null $pid
     * @return void
     */
    public function watchdog(string $jid, string $worker, ?int $pid = null)
    {
        if (empty($this->channels) || $pid === null) {
            return;
        }

        ini_set('default_socket_timeout', self::UNLIMITED);

        /**
         * Initialize a new pubsub consumer.
         *
         * @var Consumer $pubsub|null
         */
        $pubsub = $this->redis->pubSubLoop();

        $callable = [$pubsub, 'subscribe'];
        if (!is_callable($callable)) {
            return;
        }

        call_user_func_array($callable, $this->channels);

        /** @var \stdClass $message */
        foreach ($pubsub as $message) {
            if ($message->kind !== 'message' || empty($message->payload)) {
                continue;
            }

            $payload = json_decode($message->payload, true);
            if (empty($payload)) {
                continue;
            }

            if (empty($payload['event']) || !is_array($payload)) {
                continue;
            }

            if (!in_array($payload['event'], self::WATCHDOG_EVENTS, true) || empty($payload['jid'])) {
                continue;
            }

            if ($payload['jid'] !== $jid) {
                continue;
            }

            $who = 'watchdog:' . $worker;

            switch ($payload['event']) {
                case self::LOCK_LOST:
                    if (!empty($payload['worker']) && $payload['worker'] === $worker) {
                        $this->logger->info(
                            "{type}: sending SIGKILL to child {$pid}; job {jid} handed out to another worker",
                            ['type' => $who, 'jid' => $jid]
                        );

                        $this->system->posixKill($pid, SIGKILL);
                        $pubsub->stop();
                    }
                    break;
                case self::CANCELED:
                    if (!empty($payload['worker']) && $payload['worker'] === $worker) {
                        $this->logger->info(
                            "{type}: sending SIGKILL to child {$pid}; job {jid} canceled",
                            ['type' => $who, 'jid' => $jid]
                        );
                        $this->system->posixKill($pid, SIGKILL);
                        $pubsub->stop();
                    }
                    break;
                case self::COMPLETED:
                case self::FAILED:
                    $pubsub->stop();
                    break;
            }
        }

        // Always unset the pubsub consumer instance when you are done! The
        // class destructor will take care of cleanups and prevent protocol
        // desynchronizations between the client and the server.
        unset($pubsub);

        ini_set('default_socket_timeout', $this->defaultSocketTimeout);
    }
}


<?php

declare(strict_types=1);

namespace Mcfedr\AwsPushBundle\Service;

use Aws\Sns\SnsClient;
use Mcfedr\AwsPushBundle\Exception\MessageTooLongException;
use Mcfedr\AwsPushBundle\Exception\PlatformNotConfiguredException;
use Mcfedr\AwsPushBundle\Message\Message;
use Psr\Log\LoggerInterface;

class Messages
{
    /**
     * @var SnsClient
     */
    private $sns;

    /**
     * @var array
     */
    private $arns;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @var bool
     */
    private $debug;

    /**
     * @param SnsClient       $client
     * @param array           $platformARNS
     * @param LoggerInterface $logger
     * @param bool            $debug
     */
    public function __construct(SnsClient $client, array $platformARNS, bool $debug, LoggerInterface $logger = null)
    {
        $this->sns = $client;
        $this->arns = $platformARNS;
        $this->logger = $logger;
        $this->debug = $debug;
    }

    /**
     * Send a message to all devices on one or all platforms.
     *
     * @param Message $message
     * @param string  $platform
     *
     * @throws PlatformNotConfiguredException
     * @throws MessageTooLongException
     */
    public function broadcast(Message $message, ?string $platform = null)
    {
        if (null !== $platform && !isset($this->arns[$platform])) {
            throw new PlatformNotConfiguredException("There is no configured ARN for $platform");
        }

        if ($platform) {
            $this->broadcastToPlatform($message, $platform);
        } else {
            foreach ($this->arns as $platform => $arn) {
                $this->broadcastToPlatform($message, $platform);
            }
        }
    }

    /**
     * Send a message to an endpoint.
     *
     * @param Message|string $message
     * @param string         $endpointArn
     *
     * @throws MessageTooLongException
     */
    public function send($message, string $endpointArn)
    {
        if ($this->debug) {
            $this->logger && $this->logger->notice(
                "Message would have been sent to $endpointArn",
                [
                    'Message' => $message,
                ]
            );

            return;
        }

        if (!($message instanceof Message)) {
            $message = new Message($message);
        }

        $this->sns->publish(
            [
                'TargetArn' => $endpointArn,
                'Message' => $this->encodeMessage($message),
                'MessageStructure' => 'json',
            ]
        );
    }

    /**
     * @throws MessageTooLongException
     */
    private function encodeMessage(Message $message): string
    {
        try {
            $json = json_encode($message, JSON_UNESCAPED_UNICODE);

            return $json;
        } catch (\Exception $e) {
            if ($e->getPrevious() instanceof MessageTooLongException) {
                throw $e->getPrevious();
            }
            throw $e;
        }
    }

    /**
     * Send a message to all devices on a platform.
     *
     * @param Message|string $message
     * @param string         $platform
     */
    private function broadcastToPlatform($message, string $platform)
    {
        if ($this->debug) {
            $this->logger && $this->logger->notice(
                "Message would have been sent to $platform",
                [
                    'Message' => $message,
                ]
            );

            return;
        }

        foreach ($this->sns->getPaginator('ListEndpointsByPlatformApplication', [
            'PlatformApplicationArn' => $this->arns[$platform],
        ]) as $endpointsResult) {
            foreach ($endpointsResult['Endpoints'] as $endpoint) {
                if ($endpoint['Attributes']['Enabled'] == 'true') {
                    try {
                        $this->send($message, $endpoint['EndpointArn']);
                    } catch (\Exception $e) {
                        $this->logger && $this->logger->error(
                            "Failed to push to {$endpoint['EndpointArn']}",
                            [
                                'Message' => $message,
                                'Exception' => $e,
                                'Endpoint' => $endpoint,
                            ]
                        );
                    }
                } else {
                    $this->logger && $this->logger->info(
                        "Disabled endpoint {$endpoint['EndpointArn']}",
                        [
                            'Message' => $message,
                            'Endpoint' => $endpoint,
                        ]
                    );
                }
            }
        }
    }
}


<?php

declare(strict_types=1);

namespace Mcfedr\AwsPushBundle\Command;

use Aws\Sns\SnsClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class EnableAllCommand extends Command
{
    /**
     * @var SnsClient
     */
    private $sns;

    /**
     * @var array
     */
    private $arns;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @param SnsClient                $sns
     * @param array                    $arns
     * @param \Psr\Log\LoggerInterface $logger
     */
    public function __construct(SnsClient $sns, $arns, LoggerInterface $logger = null)
    {
        parent::__construct();

        $this->sns = $sns;
        $this->arns = $arns;
        $this->logger = $logger;
    }

    protected function configure()
    {
        $this
            ->setName('mcfedr:aws:enable')
            ->setDescription('Reenable all devices');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        foreach ($this->arns as $platform => $arn) {
            $this->logger && $this->logger->info("Enabling $platform");
            $this->enablePlatform($platform);
        }
    }

    /**
     * Enable all devices registered on platform.
     */
    private function enablePlatform(string $platform)
    {
        foreach ($this->sns->getPaginator('ListEndpointsByPlatformApplication', [
            'PlatformApplicationArn' => $this->arns[$platform],
        ]) as $endpointsResult) {
            foreach ($endpointsResult['Endpoints'] as $endpoint) {
                if ($endpoint['Attributes']['Enabled'] == 'false') {
                    try {
                        $this->sns->setEndpointAttributes(
                            [
                                'EndpointArn' => $endpoint['EndpointArn'],
                                'Attributes' => [
                                    'Enabled' => 'true',
                                ],
                            ]
                        );
                        $this->logger && $this->logger->info("Enabled {$endpoint['EndpointArn']}");
                    } catch (\Exception $e) {
                        $this->logger && $this->logger->error(
                            "Failed to push set attributes on {$endpoint['EndpointArn']}",
                            [
                                'exception' => $e,
                                'endpoint' => $endpoint,
                            ]
                        );
                    }
                }
            }
        }
    }
}


<?php

declare(strict_types=1);

namespace Mcfedr\AwsPushBundle\Service;

use Aws\Sns\Exception\SnsException;
use Aws\Sns\SnsClient;
use Mcfedr\AwsPushBundle\Exception\PlatformNotConfiguredException;

class Devices
{
    /**
     * @var SnsClient
     */
    private $sns;

    /**
     * @var array
     */
    private $arns;

    /**
     * @param SnsClient $client
     * @param array     $platformARNS
     */
    public function __construct(SnsClient $client, array $platformARNS)
    {
        $this->sns = $client;
        $this->arns = $platformARNS;
    }

    /**
     * Register a device token.
     *
     * @param string $deviceId device token
     * @param string $platform platform on which to register
     * @param string $userData Arbitrary user data to associate with the endpoint
     *
     * @return string the endpoint ARN for this device
     *
     * @throws PlatformNotConfiguredException
     * @throws \Exception
     */
    public function registerDevice(string $deviceId, string $platform, ?string $userData = null): string
    {
        if (!isset($this->arns[$platform])) {
            throw new PlatformNotConfiguredException("There is no configured ARN for $platform");
        }

        try {
            $args = [
                'PlatformApplicationArn' => $this->arns[$platform],
                'Token' => $deviceId,
                'Attributes' => [
                    'Enabled' => 'true',
                ],
            ];

            if ($userData) {
                $args['CustomUserData'] = $userData;
            }

            $res = $this->sns->createPlatformEndpoint($args);
        } catch (SnsException $e) {
            preg_match('/Endpoint (.+?) already/', $e->getMessage(), $matches);
            if (isset($matches[1])) {
                $this->sns->setEndpointAttributes(
                    [
                        'EndpointArn' => $matches[1],
                        'Attributes' => [
                            'Enabled' => 'true',
                        ],
                    ]
                );

                return $matches[1];
            } else {
                throw $e;
            }
        }

        return $res['EndpointArn'];
    }

    /**
     * Unregister a device, using its endpoint ARN.
     */
    public function unregisterDevice(string $endpoint)
    {
        $this->sns->deleteEndpoint(
            [
                'EndpointArn' => $endpoint,
            ]
        );
    }

    /**
     * Returns a list of configured platforms.
     */
    public function validPlatforms(): array
    {
        return array_keys($this->arns);
    }
}


<?php

declare(strict_types=1);

namespace Mcfedr\AwsPushBundle\Service;

use Aws\Sns\SnsClient;
use Mcfedr\AwsPushBundle\Message\Message;
use Psr\Log\LoggerInterface;

/**
 * @deprecated Use the SnsClient directly to deal with topics
 * @see SnsClient
 */
class Topics
{
    /**
     * @var SnsClient
     */
    private $sns;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @var Messages
     */
    private $messages;

    /**
     * @var bool
     */
    private $debug;

    /**
     * @param SnsClient       $client
     * @param LoggerInterface $logger
     * @param Messages        $messages
     * @param $debug
     */
    public function __construct(SnsClient $client, Messages $messages, bool $debug, LoggerInterface $logger = null)
    {
        $this->sns = $client;
        $this->logger = $logger;
        $this->messages = $messages;
        $this->debug = $debug;
    }

    /**
     * Create a topic.
     *
     * @param string $name Topic name
     *
     * @return string The topic ARN
     *
     * @deprecated use SnsClient directly to subscribe
     */
    public function createTopic(string $name): string
    {
        $res = $this->sns->createTopic(
            [
                'Name' => $name,
            ]
        );

        return $res['TopicArn'];
    }

    /**
     * Delete a topic.
     *
     * @param string $topicArn Topic ARN
     *
     * @deprecated use SnsClient directly to subscribe
     */
    public function deleteTopic(string $topicArn)
    {
        $this->sns->deleteTopic(
            [
                'TopicArn' => $topicArn,
            ]
        );
    }

    /**
     * Subscribe a device to the topic, will create new numbered topics
     * once the first is full.
     *
     * @param string $deviceArn
     * @param string $topicArn  The base name of the topics to use
     *
     * @deprecated use SnsClient directly to subscribe
     * @see SnsClient::subscribe
     */
    public function registerDeviceOnTopic(string $deviceArn, string $topicArn)
    {
        $this->sns->subscribe(
            [
                'TopicArn' => $topicArn,
                'Protocol' => 'application',
                'Endpoint' => $deviceArn,
            ]
        );
    }

    /**
     * Send a message to all topics in the group.
     *
     * @param Message $message
     * @param string  $topicArn
     *
     * @deprecated Use Messages send method and pass the topicArn as the destination
     * @see Messages::send
     */
    public function broadcast(Message $message, string $topicArn)
    {
        if ($this->debug) {
            $this->logger && $this->logger->notice(
                "Message would have been sent to $topicArn",
                [
                    'Message' => $message,
                ]
            );

            return;
        }

        $this->messages->send($message, $topicArn);
    }
}


<?php

declare(strict_types=1);

namespace Mcfedr\AwsPushBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

/**
 * This is the class that validates and merges configuration from your app/config files.
 *
 * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html#cookbook-bundles-extension-config-class}
 */
class Configuration implements ConfigurationInterface
{
    /**
     * {@inheritdoc}
     */
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder('mcfedr_aws_push');

        if (method_exists($treeBuilder, 'getRootNode')) {
            $rootNode = $treeBuilder->getRootNode();
        } else {
            // BC layer for symfony/config 4.1 and older
            $rootNode = $treeBuilder->root('mcfedr_aws_push');
        }

        $rootNode
            ->children()
                ->arrayNode('aws')
                    ->children()
                        ->variableNode('credentials')->end()
                        ->scalarNode('key')->cannotBeEmpty()->setDeprecated('The "%node%" option is deprecated. Use "credentials" instead.')->end()
                        ->scalarNode('secret')->cannotBeEmpty()->setDeprecated('The "%node%" option is deprecated. Use "credentials" instead.')->end()
                        ->scalarNode('region')->cannotBeEmpty()->end()
                    ->end()
                ->end()
                ->variableNode('platforms')->end()
                ->booleanNode('debug')->defaultFalse()->end()
                ->scalarNode('topic_arn')->cannotBeEmpty()->end()
            ->end()
        ->end();

        // Here you should define the parameters that are allowed to
        // configure your bundle. See the documentation linked above for
        // more information on that topic.

        return $treeBuilder;
    }
}


<?php

declare(strict_types=1);

namespace Mcfedr\AwsPushBundle\Command;

use Aws\Sns\SnsClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class RemoveDisabledCommand extends Command
{
    /**
     * @var SnsClient
     */
    private $sns;

    /**
     * @var array
     */
    private $arns;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @param SnsClient                $sns
     * @param array                    $arns
     * @param \Psr\Log\LoggerInterface $logger
     */
    public function __construct(SnsClient $sns, $arns, LoggerInterface $logger = null)
    {
        parent::__construct();

        $this->sns = $sns;
        $this->arns = $arns;
        $this->logger = $logger;
    }

    protected function configure()
    {
        $this
            ->setName('mcfedr:aws:remove')
            ->setDescription('Remove disabled devices');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        foreach ($this->arns as $platform => $arn) {
            $this->logger && $this->logger->info("Removing from $platform");
            $this->removeFromPlatform($platform);
        }
    }

    /**
     * Enable all devices registered on platform.
     */
    private function removeFromPlatform(string $platform)
    {
        foreach ($this->sns->getPaginator('ListEndpointsByPlatformApplication', [
            'PlatformApplicationArn' => $this->arns[$platform],
        ]) as $endpointsResult) {
            foreach ($endpointsResult['Endpoints'] as $endpoint) {
                if ($endpoint['Attributes']['Enabled'] == 'false') {
                    try {
                        $this->sns->deleteEndpoint(
                            [
                                'EndpointArn' => $endpoint['EndpointArn'],
                            ]
                        );
                        $this->logger && $this->logger->info("Removed {$endpoint['EndpointArn']}");
                    } catch (\Exception $e) {
                        $this->logger && $this->logger->error(
                            "Failed to remove endpoint {$endpoint['EndpointArn']}",
                            [
                                'exception' => $e,
                                'endpoint' => $endpoint,
                            ]
                        );
                    }
                }
            }
        }
    }
}