├── LICENSE ├── README.md ├── composer.json └── src ├── Adapter ├── AbstractAdapter.php ├── Amazon │ └── DynamoDb │ │ └── QueueTableRow.php ├── ApnsdPush.php ├── Beanstalk.php ├── Beanstalk │ └── Client.php ├── DynamoSQS.php ├── Fcm.php ├── IO │ ├── AbstractIO.php │ ├── Exception │ │ ├── IOException.php │ │ ├── RuntimeException.php │ │ └── TimeoutException.php │ └── StreamIO.php ├── Nsq.php ├── Redis.php └── Redis │ ├── App.php │ ├── Connector.php │ └── Queue.php ├── Logger.php ├── Message ├── AbstractMessage.php ├── Amazon │ └── SNS │ │ └── Application │ │ └── PlatformEndpoint │ │ ├── Publish.php │ │ ├── PublishMessageInterface.php │ │ ├── Register.php │ │ ├── RegisterMessageInterface.php │ │ ├── Remove.php │ │ └── RemoveMessageInterface.php ├── ApnsPHP.php ├── Closure.php ├── ConsumeInterface.php ├── Fcm.php ├── Generic.php ├── Guzzle.php ├── Process.php └── Serialized.php ├── Publisher ├── AbstractPublisher.php ├── Amazon │ └── SNS │ │ └── Application │ │ └── PlatformEndpoint │ │ ├── Publish.php │ │ ├── Register.php │ │ └── Remove.php ├── Apnsd.php ├── Closure.php ├── Fcm.php ├── Guzzle.php ├── Process.php └── Serialized.php ├── Worker ├── AProcess.php ├── AbstractWorker.php ├── Amazon │ └── SNS │ │ ├── Application.php │ │ ├── Application │ │ ├── PlatformEndpoint.php │ │ └── PlatformEndpoint │ │ │ ├── Publish.php │ │ │ ├── Register.php │ │ │ └── Remove.php │ │ ├── Client │ │ └── Exception │ │ │ ├── NetworkException.php │ │ │ └── SnsException.php │ │ └── SnsClient.php ├── Apnsd.php ├── Closure.php ├── Closure │ └── RecoverableException.php ├── Fcm.php ├── Guzzle.php └── Serialized.php └── Zend └── Http └── Client └── Adapter └── Psr7.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013-2022 Sergei Shilko 2 | Copyright 2016-2019 Carolina Alarcon 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | BackQ - Component 2 | ================= 3 | 4 | Background **queue processing** - publish tasks and process with workers, simplified. 5 | 6 | * Sending [APNS](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html#//apple_ref/doc/uid/TP40008194-CH100-SW9) push notifications (Legacy API) 7 | * Sending [FCM](https://firebase.google.com/docs/cloud-messaging) push notifications to Android (GCM/FCM) 8 | * Sending [AWS SNS](https://aws.amazon.com/sns/) push notifications via AWS SNS arn's 9 | * Executing [Psr7\Request](https://www.php-fig.org/psr/psr-7/) asynchronously via Guzzle 10 | * Executing **any** processes with [symfony/process](http://symfony.com/doc/current/components/process.html) 11 | * [Long delay scheduling](https://aws.amazon.com/blogs/aws/new-manage-dynamodb-items-using-time-to-live-ttl/) via DynamoSQS Adapter and Serialized worker, for reliable long-term scheduled jobs 12 | * Extendable - write your own worker and use existing adapters out of the box ... 13 | 14 | #### Installation 15 | 16 | ``` 17 | #composer self-update && composer clear-cache && composer diagnose 18 | composer require sshilko/backq:^3.0 19 | ``` 20 | 21 | #### Example with Redis adapter and `process` worker 22 | ``` 23 | #launch local redis 24 | docker run -d --name=example-backq-redis --network=host redis 25 | 26 | #install library in any folder for testing 27 | mkdir /tmp/example && cd /tmp/example 28 | composer require sshilko/backq:^3.0 29 | 30 | #post job to queue (schedule) 31 | cd vendor/sshilko/backq/example/publishers/process && php redis.php && cd /tmp/example 32 | 33 | #[debug] connect 34 | #[debug] _connect 35 | #[debug] putTask 36 | #[debug] putTask is connected and ready to: write 37 | #[debug] putTask pushed task without delay xoOgPKcS9bIDVXSaLYH9aLB22gzzptRo 38 | #[debug] putTask return 'xoOgPKcS9bIDVXSaLYH9aLB22gzzptRo' 39 | #Published process message via redis adapter as ID=xoOgPKcS9bIDVXSaLYH9aLB22gzzptRo 40 | 41 | #fetch job from queue (work) 42 | cd vendor/sshilko/backq/example/workers/process && php redis.php && cd /tmp/example 43 | 44 | #[debug] connect 45 | #[debug] _connect 46 | #[debug] pickTask 47 | #[debug] pickTask blocking for 5 seconds until get a job 48 | #[debug] pickTask reserved a job nOgykJV81g969yw2wRMF94V9KiIeKN4P 49 | #[debug] afterWorkSuccess 50 | #[debug] afterWorkSuccess currently 1 reserved job(s) 51 | #[debug] afterWorkSuccess releasing completed nOgykJV81g969yw2wRMF94V9KiIeKN4P job 52 | #[debug] Disconnecting 53 | #[debug] Disconnecting, previously connected 54 | #[debug] Disconnecting, state detected 55 | #[debug] Disconnecting, state detected, queue is connected 56 | #[debug] Disconnecting, state 0 jobs reserved and not finalized 57 | #[debug] Disconnecting, state detected, disconnecting queue manager 58 | #[debug] Disconnecting, successful 59 | 60 | #verify job executed (example process worker does echo $( date +%s ) >> /tmp/test) 61 | cat /tmp/test 62 | 63 | docker stop example-backq-redis 64 | ``` 65 | 66 | #### Supported queue servers 67 | 68 | * [Beanstalkd](https://github.com/kr/beanstalkd/blob/master/doc/protocol.txt) 69 | * [Redis](https://redis.io) 70 | * [NSQ](https://nsq.io) 71 | * [DynamoDB](https://aws.amazon.com/dynamodb/) [SQS](https://aws.amazon.com/sqs/) [Lambda](https://aws.amazon.com/lambda/) for DynamoSQS adapter 72 | 73 | #### Features 74 | 75 | Workers compatibility with adapters 76 | 77 | | Adapter / Worker |[FCM](https://firebase.google.com/docs/cloud-messaging)|[APNS](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html#//apple_ref/doc/uid/TP40008194-CH100-SW9)|[Process](http://symfony.com/doc/current/components/process.html)|[Guzzle](https://www.php-fig.org/psr/psr-7/)|Serialized|[AWS SNS](https://aws.amazon.com/sns/)|[Closure](https://github.com/opis/closure)| 78 | |----|---|---|---|---|---|---|---| 79 | | [Beanstalkd](https://beanstalkd.github.io/) | + | + | + | + | + | + | + | 80 | | [Redis](https://redis.io) | + | + | + | + | ? | + | + | 81 | | [NSQ](https://nsq.io/) | + | + | + | + | ? | + | ? | 82 | | [DynamoSQS](https://aws.amazon.com/) | + | + | + | + | + | ? | + | 83 | 84 | Adapter implemented features 85 | 86 | | Adapter / Feature | ping | hasWorkers | setWorkTimeout | 87 | |---|---|---|---| 88 | | [Beanstalkd](https://beanstalkd.github.io/) | + | + | + 89 | | [Redis](https://redis.io) | + | - | + 90 | | [NSQ](https://nsq.io/) | + | - | * 91 | | [DynamoSQS](https://aws.amazon.com/) | - | - | + 92 | 93 | Worker available features 94 | 95 | - `setRestartThreshold` (limit max number of jobs cycles, then terminate) 96 | - `setIdleTimeout` (limit max idle time, then terminating) 97 | 98 | TLDR 99 | 100 | ![Backq](https://github.com/sshilko/backq/raw/master/example/example.jpg "Background tasks with workers and publishers via queues") 101 | 102 | See [/example](https://github.com/sshilko/backq/tree/master/example) folder for usage examples 103 | 104 | #### Old version 1 detailed review 105 | 106 | [Blog post about sending Apple push notifications](http://moar.sshilko.com/2014/09/09/APNS-Workers/) 107 | 108 | #### Licence 109 | MIT 110 | 111 | Copyright 2013-2022 Sergei Shilko 112 | Copyright 2016-2019 Carolina Alarcon 113 | 114 | 115 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sshilko/backq", 3 | "license": "MIT", 4 | "authors": [ 5 | { 6 | "name": "Sergei Shilko", 7 | "email": "contact@sshilko.com", 8 | "homepage": "https://github.com/sshilko", 9 | "role": "Developer" 10 | } 11 | ], 12 | "config": { 13 | "vendor-dir": "vendor", 14 | "preferred-install": { 15 | "*": "dist" 16 | }, 17 | "prepend-autoloader": false, 18 | "optimize-autoloader": true, 19 | "classmap-authoritative": true, 20 | "process-timeout": 360, 21 | "sort-packages": true, 22 | "allow-plugins": { 23 | "dealerdirect/phpcodesniffer-composer-installer": true 24 | } 25 | }, 26 | "description": "Background jobs processing with queue, workers & publishers", 27 | "type": "library", 28 | "minimum-stability": "dev", 29 | "support": { 30 | "issues": "https://github.com/sshilko/backq/issues" 31 | }, 32 | "keywords": ["queue", "worker", "apns", "push", "ios", "fcm", "apple", "guzzle", "process", "notification", "sqs", "dynamodb", "send", "background", "async"], 33 | "homepage": "https://github.com/sshilko/backq", 34 | "require": { 35 | "php": ">=7.2", 36 | "ext-redis": "*", 37 | "davidpersson/beanstalk": "^2.0", 38 | "symfony/process": ">=4", 39 | "duccio/apns-php": "=1.0.1", 40 | "illuminate/queue": ">=5", 41 | "illuminate/redis": ">=5", 42 | "aws/aws-sdk-php": "^3.133", 43 | "opis/closure": "^3.6", 44 | "psr/log":"^1.1" 45 | }, 46 | "require-dev": { 47 | "ext-posix": "*", 48 | "ext-ast": "*", 49 | "nikic/php-parser": "^4", 50 | "pdepend/pdepend": "^2.12", 51 | "phan/phan": "^5.4", 52 | "phpmd/phpmd": "^2.13", 53 | "phpstan/phpstan": "^1.8", 54 | "psalm/phar": "*", 55 | "slevomat/coding-standard": "^8.4", 56 | "squizlabs/php_codesniffer": "^3.7" 57 | }, 58 | "autoload": { 59 | "psr-4": { "BackQ\\": "src/" } 60 | }, 61 | "scripts": { 62 | "app-code-quality": [ 63 | "@app-phpcbf", 64 | "@app-phpcs", 65 | "@app-pdepend", 66 | "@app-phpmd", 67 | "@app-phpstan", 68 | "@app-psalm-alter", 69 | "@app-psalm-taint", 70 | "@app-psalm", 71 | "@app-phan" 72 | ], 73 | "app-psalm":[ 74 | "@putenv XDEBUG_MODE=off", 75 | "php ./vendor/bin/psalm.phar --php-version=$(php -r 'echo PHP_VERSION;') --config build/psalm.xml --memory-limit=-1 --no-diff --show-info=true --long-progress --stats --disable-extension=xdebug" 76 | ], 77 | "app-psalm-shepherd":[ 78 | "@putenv XDEBUG_MODE=off", 79 | "php ./vendor/bin/psalm.phar --php-version=$(php -r 'echo PHP_VERSION;') --config build/psalm.xml --shepherd --long-progress --memory-limit=-1 --no-diff --disable-extension=xdebug" 80 | ], 81 | "app-psalm-alter": [ 82 | "@putenv XDEBUG_MODE=off", 83 | "php ./vendor/bin/psalm.phar --php-version=$(php -r 'echo PHP_VERSION;') --config build/psalm.xml --alter --issues=MissingParamType,MissingReturnType,InvalidReturnType,InvalidNullableReturnType,InvalidFalsableReturnType,PossiblyUndefinedVariable,UnnecessaryVarAnnotation,ParamNameMismatch" 84 | ], 85 | "app-psalm-taint": [ 86 | "@putenv XDEBUG_MODE=off", 87 | "php ./vendor/bin/psalm.phar --php-version=$(php -r 'echo PHP_VERSION;') --config build/psalm.xml --taint-analysis --long-progress --disable-extension=xdebug" 88 | ], 89 | "app-phpcbf":[ 90 | "@putenv XDEBUG_MODE=off", 91 | "pre-commit run --all-files --config build/.pre-commit-config.yaml php-code-phpcbf" 92 | ], 93 | "app-phpcs":[ 94 | "@putenv XDEBUG_MODE=off", 95 | "pre-commit run --all-files --config build/.pre-commit-config.yaml php-code-phpcs" 96 | ], 97 | "app-phpstan":[ 98 | "@putenv XDEBUG_MODE=off", 99 | "pre-commit run --all-files --config build/.pre-commit-config.yaml php-code-phpstan" 100 | ], 101 | "app-phpmd": [ 102 | "@putenv XDEBUG_MODE=off", 103 | "php ./vendor/phpmd/phpmd/src/bin/phpmd src/ ansi build/phpmd-rulesets.xml" 104 | ], 105 | "app-phan": [ 106 | "@putenv XDEBUG_MODE=off", 107 | "@putenv PHAN_DISABLE_XDEBUG_WARN=1", 108 | "@putenv PHAN_ALLOW_XDEBUG=1", 109 | "php ./vendor/bin/phan --color -k ./build/phan.php" 110 | ], 111 | "app-pdepend": [ 112 | "php ./vendor/bin/pdepend --configuration=$PWD/build/pdepend.xml --dependency-xml=$PWD/build/tmp/pdepend-dependency-xml.xml --jdepend-chart=$PWD/build/tmp/pdepend-jdepend-chart.svg --jdepend-xml=$PWD/build/tmp/pdepend-jdepend-xml.xml --summary-xml=$PWD/build/tmp/pdepend-summary-xml.xml --overview-pyramid=$PWD/build/tmp/pdepend-overview-pyramid.svg src" 113 | ] 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Adapter/AbstractAdapter.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 96 | } 97 | 98 | /** 99 | * @param bool $triggerError 100 | */ 101 | public function setTriggerErrorOnError(bool $triggerError): void 102 | { 103 | $this->triggerErrorOnError = $triggerError; 104 | } 105 | 106 | /** 107 | * @param string $message 108 | */ 109 | public function logInfo(string $message): void 110 | { 111 | if ($this->logger) { 112 | $this->logger->info($message); 113 | } 114 | } 115 | 116 | /** 117 | * @param string $message 118 | */ 119 | public function logDebug(string $message): void 120 | { 121 | if ($this->logger) { 122 | $this->logger->debug($message); 123 | } 124 | } 125 | 126 | /** 127 | * @param string $message 128 | */ 129 | public function logError(string $message): void 130 | { 131 | if ($this->logger) { 132 | $this->logger->error($message); 133 | } 134 | 135 | if ($this->triggerErrorOnError) { 136 | trigger_error($message, E_USER_WARNING); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Adapter/Amazon/DynamoDb/QueueTableRow.php: -------------------------------------------------------------------------------- 1 | id = uniqid($queueId . '.', false); 34 | $this->payload = $body; 35 | $this->time_ready = (string) $timeReady; 36 | $this->metadata['payload_checksum'] = $this->calculateHMAC(); 37 | } 38 | 39 | public static function fromArray(array $array): ?self 40 | { 41 | if (!isset($array['id'], $array['metadata'], $array['payload'], $array['time_ready'])) { 42 | return null; 43 | } 44 | 45 | $item = new self($array['payload'], $array['time_ready']); 46 | $metadata = json_decode($array['metadata'], true); 47 | 48 | /** 49 | * Verify that the payload checksum corresponds to the payload 50 | */ 51 | if ($metadata['payload_checksum'] !== $item->calculateHMAC()) { 52 | return null; 53 | } 54 | 55 | return $item; 56 | } 57 | 58 | public function toArray(): array 59 | { 60 | return ['id' => [self::DYNAMODB_TYPE_STRING => $this->id], 61 | 'metadata' => [self::DYNAMODB_TYPE_STRING => json_encode($this->metadata)], 62 | 'payload' => [self::DYNAMODB_TYPE_STRING => $this->payload], 63 | 'time_ready' => [self::DYNAMODB_TYPE_NUMBER => $this->time_ready], 64 | ]; 65 | } 66 | 67 | public function getPayload(): string 68 | { 69 | return $this->payload; 70 | } 71 | 72 | private function calculateHMAC(): int 73 | { 74 | return crc32($this->payload); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Adapter/ApnsdPush.php: -------------------------------------------------------------------------------- 1 | _nReadWriteTimeout = $seconds; 77 | } 78 | 79 | /** 80 | * Disconnects from Apple Push Notifications service server. 81 | * Does not return anything 82 | * @IMPORTANT ApnsPHP_Push.disconnect() returned boolean (but never used result); 83 | * @IMPORTANT may break compatibility 84 | */ 85 | public function disconnect(): void 86 | { 87 | if ($this->io) { 88 | $this->io->close(); 89 | $this->io = null; 90 | } 91 | } 92 | 93 | /** 94 | * Sends all messages in the message queue to Apple Push Notification Service. 95 | * 96 | * @throws ApnsPHP_Push_Exception if not connected to the 97 | * service or no notification queued. 98 | */ 99 | public function send(): void 100 | { 101 | if (empty($this->_aMessageQueue)) { 102 | throw new ApnsPHP_Push_Exception('No notifications queued to be sent'); 103 | } 104 | 105 | $this->_aErrors = []; 106 | $nRun = 1; 107 | 108 | while (($nMessages = count($this->_aMessageQueue)) > 0) { 109 | $this->_log("INFO: Processing messages queue, run #{$nRun}: $nMessages message(s) left in queue."); 110 | 111 | foreach ($this->_aMessageQueue as $k => &$aMessage) { 112 | if (!empty($aMessage['ERRORS'])) { 113 | foreach ($aMessage['ERRORS'] as $aError) { 114 | switch ($aError['statusCode']) { 115 | case 0: 116 | /** 117 | * No error 118 | */ 119 | $this->_log( 120 | "INFO: Message ID {$k} has no error ({$aError['statusCode']}), removing from queue..." 121 | ); 122 | $this->_removeMessageFromQueue($k); 123 | 124 | continue 2; 125 | 126 | break; 127 | default: 128 | /** 129 | * Errors 130 | */ 131 | $this->_log( 132 | "WARNING: Message ID {$k} has error ({$aError['statusCode']}), removing from queue..." 133 | ); 134 | $this->_removeMessageFromQueue($k, true); 135 | 136 | continue 2; 137 | 138 | break; 139 | } 140 | } 141 | } else { 142 | /** 143 | * Send Message --> 144 | */ 145 | if (function_exists('pcntl_signal_dispatch')) { 146 | pcntl_signal_dispatch(); 147 | } 148 | $this->_log("STATUS: Sending #{$k}: " . strlen($aMessage['BINARY_NOTIFICATION']) . " bytes"); 149 | 150 | $readyWrite = $this->io->selectWrite(0, $this->_nSocketSelectTimeout); 151 | 152 | if (false === $readyWrite) { 153 | $this->_log('ERROR: Unable to wait for a write availability.'); 154 | 155 | throw new ApnsPHP_Push_Exception('Failed to select io stream for writing'); 156 | } 157 | 158 | try { 159 | $this->io->write($aMessage['BINARY_NOTIFICATION']); 160 | } catch (Throwable $e) { 161 | /** 162 | * No reason to continue, failed to write explicitly 163 | */ 164 | throw new ApnsPHP_Push_Exception($e->getMessage()); 165 | } 166 | /** 167 | * Send Message <-- 168 | */ 169 | 170 | 171 | /** 172 | * Read Response --> 173 | */ 174 | $nChangedStreams = $this->io->selectRead(0, $this->_nSocketSelectTimeout); 175 | 176 | if (false === $nChangedStreams) { 177 | $this->_log('ERROR: Unable to wait for a stream read availability.'); 178 | 179 | throw new ApnsPHP_Push_Exception('Failed to select io stream for reading'); 180 | } 181 | 182 | if (0 === $nChangedStreams) { 183 | /** 184 | * After successful publish nothing is expected in response 185 | * Timed-Out while waiting before anything interesting happened 186 | */ 187 | $this->_aMessageQueue = []; 188 | } elseif ($nChangedStreams > 0) { 189 | /** 190 | * Read the error message (or nothing) from stream and update the queue/cycle 191 | */ 192 | if ($this->_updateQueue()) { 193 | /** 194 | * APNs returns an error-response packet and closes the connection 195 | * 196 | * _updateQueue modified the _aMessageQueue so it contains the error data, 197 | * next cycle will deal with errors 198 | */ 199 | } else { 200 | /** 201 | * If you send a notification that is accepted by APNs, nothing is returned. 202 | */ 203 | $this->_aMessageQueue = []; 204 | } 205 | } 206 | /** 207 | * Read Response <-- 208 | */ 209 | } 210 | } 211 | 212 | $nRun++; 213 | } 214 | } 215 | 216 | protected function _connect() 217 | { 218 | [$shost, $sport] = $this->_serviceURLs[$this->_nEnvironment]; 219 | try { 220 | /** 221 | * @see http://php.net/manual/en/context.ssl.php 222 | */ 223 | $ssl = ['verify_peer' => isset($this->_sRootCertificationAuthorityFile), 224 | 'cafile' => $this->_sRootCertificationAuthorityFile, 225 | 'local_cert' => $this->_sProviderCertificateFile, 226 | 'disable_compression' => true]; 227 | 228 | /** 229 | * Enabling SNI allows multiple certificates on the same IP address 230 | * @see http://php.net/manual/en/context.ssl.php 231 | * @see http://php.net/manual/en/openssl.constsni.php 232 | */ 233 | if (defined('OPENSSL_TLSEXT_SERVER_NAME')) { 234 | $ssl['SNI_enabled'] = true; 235 | } 236 | 237 | $streamContext = stream_context_create(['ssl' => $ssl]); 238 | 239 | $this->io = new IO\StreamIO( 240 | $shost, 241 | $sport, 242 | $this->_nConnectTimeout, 243 | $this->_nReadWriteTimeout, 244 | $streamContext 245 | ); 246 | } catch (Throwable $e) { 247 | throw new ApnsPHP_Exception("Unable to connect: " . $e->getMessage()); 248 | } 249 | 250 | return true; 251 | } 252 | 253 | /** 254 | * APNs 255 | * 1. returns an error-response packet 256 | * 2. closes the connection 257 | * 258 | * Reads an error message (if present) from the main stream. 259 | * If the error message is present and valid the error message is returned, 260 | * otherwhise null is returned. 261 | * 262 | * @return @type array|null Return the error message array. 263 | */ 264 | protected function _readErrorMessage() 265 | { 266 | try { 267 | $sErrorResponse = $this->io->read(self::ERROR_RESPONSE_SIZE); 268 | } catch (Throwable $e) { 269 | /** 270 | * Read IO exception exposed as Push exception so its catched properly 271 | */ 272 | throw new ApnsPHP_Push_Exception($e->getMessage()); 273 | } 274 | 275 | if (!$sErrorResponse) { 276 | /** 277 | * No response from APNS in (some period) time 278 | */ 279 | return null; 280 | } 281 | 282 | if (self::ERROR_RESPONSE_SIZE !== strlen($sErrorResponse)) { 283 | throw new RuntimeException( 284 | 'Unexpected response size: ' . strlen($sErrorResponse) 285 | ); 286 | } 287 | 288 | $aErrorResponse = unpack('Ccommand/CstatusCode/Nidentifier', $sErrorResponse); 289 | 290 | if (empty($aErrorResponse)) { 291 | /** 292 | * In theory unpack ALWAYS returns array: 293 | * Returns an associative array containing unpacked elements of binary string. 294 | * 295 | * @see http://php.net/manual/en/function.unpack.php 296 | */ 297 | throw new RuntimeException('Failed to unpack response data'); 298 | } 299 | 300 | if (!isset($aErrorResponse['command'], $aErrorResponse['statusCode'], $aErrorResponse['identifier']) 301 | || self::ERROR_RESPONSE_COMMAND !== $aErrorResponse['command']) { 302 | throw new RuntimeException( 303 | 'Unpacked error response has unexpected format: ' . json_encode($aErrorResponse) 304 | ); 305 | } 306 | 307 | $aErrorResponse['time'] = time(); 308 | 309 | $errMsg = 'Unknown error code: ' . $aErrorResponse['statusCode']; 310 | switch ($aErrorResponse['statusCode']) { 311 | case 0: 312 | $errMsg = 'No errors encountered'; 313 | 314 | break; 315 | case 1: 316 | $errMsg = 'Processing error'; 317 | 318 | break; 319 | case 2: 320 | $errMsg = 'Missing device token'; 321 | 322 | break; 323 | case 3: 324 | $errMsg = 'Missing topic'; 325 | 326 | break; 327 | case 4: 328 | $errMsg = 'Missing payload'; 329 | 330 | break; 331 | case 5: 332 | $errMsg = 'Invalid token size'; 333 | 334 | break; 335 | case 6: 336 | $errMsg = 'Invalid topic size'; 337 | 338 | break; 339 | case 7: 340 | $errMsg = 'Invalid payload size'; 341 | 342 | break; 343 | case 8: 344 | $errMsg = 'Invalid token'; 345 | 346 | break; 347 | case 10: 348 | $errMsg = 'Shutdown'; 349 | 350 | break; 351 | case 128: 352 | $errMsg = 'Protocol error'; 353 | 354 | break; 355 | case 255: 356 | $errMsg = 'None (unknown)'; 357 | 358 | break; 359 | } 360 | $aErrorResponse['statusMessage'] = $errMsg; 361 | 362 | return $aErrorResponse; 363 | } 364 | 365 | /** 366 | * Checks for error message and deletes messages successfully sent from message queue. 367 | * 368 | * @return bool whether error was detected. 369 | */ 370 | protected function _updateQueue($aErrorMessage = null): bool 371 | { 372 | $error = $this->_readErrorMessage(); 373 | 374 | if (empty($error)) { 375 | /** 376 | * If you send a notification that is accepted by APNs, nothing is returned. 377 | */ 378 | return false; 379 | } 380 | 381 | $this->_log('ERROR: Unable to send message ID ' . 382 | $error['identifier'] . ': ' . 383 | $error['statusMessage'] . ' (' . $error['statusCode'] . ')'); 384 | 385 | foreach ($this->_aMessageQueue as $k => &$aMessage) { 386 | if ($k < $error['identifier']) { 387 | /** 388 | * Messages before X were successful 389 | */ 390 | unset($this->_aMessageQueue[$k]); 391 | } elseif ($k === $error['identifier']) { 392 | /** 393 | * Append error to message error's list 394 | */ 395 | $aMessage['ERRORS'][] = $error; 396 | 397 | break; 398 | } 399 | 400 | throw new ApnsPHP_Push_Exception('Received error for unknown message identifier: ' . $error['identifier']); 401 | 402 | break; 403 | } 404 | 405 | return true; 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/Adapter/Beanstalk.php: -------------------------------------------------------------------------------- 1 | connected && $this->client) { 48 | return true; 49 | } 50 | 51 | try { 52 | $bconfig = ['host' => $host, 53 | 'port' => $port, 54 | 'timeout' => $timeout, 55 | 'persistent' => $persistent, 56 | 'logger' => ($logger ?: $this)]; 57 | 58 | //$this->client = new \Beanstalk\Client($bconfig); 59 | $this->client = new Client($bconfig); 60 | 61 | if ($this->client->connect()) { 62 | $this->connected = true; 63 | 64 | return true; 65 | } 66 | } catch (Throwable $e) { 67 | $this->error('Beanstalk adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 68 | } 69 | 70 | return false; 71 | } 72 | 73 | public function setWorkTimeout(?int $seconds = null) 74 | { 75 | $this->workTimeout = $seconds; 76 | 77 | return null; 78 | } 79 | 80 | /** 81 | * This overrides the original Beanstalkd logger 82 | * @see \Beanstalk\Client._error() 83 | * @param $msg 84 | */ 85 | public function error($msg): void 86 | { 87 | $this->logError($msg); 88 | } 89 | 90 | /** 91 | * Checks (if possible) if there are workers to work immediately 92 | * 93 | */ 94 | public function hasWorkers($queue = false): ?int 95 | { 96 | if ($this->connected) { 97 | try { 98 | if ($queue) { 99 | # $definedtubes = $this->client->listTubes(); 100 | # if (!empty($definedtubes) && in_array($queue, $definedtubes)) { 101 | # Because we already binded to a queue, it will be always shown in list 102 | 103 | /** 104 | * Workers watching queue 105 | * 106 | * rarely fails with NOT_FOUND even when we binded (use %tube) successfuly before 107 | * failure produces error-log entries 108 | */ 109 | $result = $this->client->statsTube($queue); 110 | if ($result && is_array($result) && isset($result['current-watching'])) { 111 | return $result['current-watching']; 112 | } 113 | } else { 114 | /** 115 | * Workers at all connected (not very usefull) 116 | */ 117 | $result = $this->client->stats(); 118 | if ($result && is_array($result) && isset($result['current-workers'])) { 119 | return $result['current-workers']; 120 | } 121 | } 122 | } catch (RuntimeException $e) { 123 | $this->error(__FUNCTION__ . ' ' . $e->getMessage()); 124 | } 125 | } 126 | 127 | return null; 128 | } 129 | 130 | /** 131 | * Returns TRUE if connection is alive 132 | */ 133 | public function ping($reconnect = true) 134 | { 135 | try { 136 | /** 137 | * @todo Any other fast && reliable options to check if socket is alive? 138 | */ 139 | $result = $this->client->stats(); 140 | if ($result) { 141 | return true; 142 | } 143 | 144 | if ($reconnect) { 145 | if (true === $this->client->connect()) { 146 | return $this->ping(false); 147 | } 148 | } 149 | } catch (RuntimeException $e) { 150 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 151 | } 152 | } 153 | 154 | /** 155 | * Subscribe for new incoming data 156 | * 157 | */ 158 | public function bindRead($queue): bool 159 | { 160 | if ($this->connected) { 161 | try { 162 | if ($this->client->watch($queue)) { 163 | return true; 164 | } 165 | } catch (Throwable $e) { 166 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 167 | } 168 | } 169 | 170 | return false; 171 | } 172 | 173 | /** 174 | * Prepare to write data into queue 175 | * 176 | */ 177 | public function bindWrite($queue): bool 178 | { 179 | if ($this->connected) { 180 | try { 181 | if ($this->client->useTube($queue)) { 182 | return true; 183 | } 184 | } catch (Throwable $e) { 185 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 186 | } 187 | } 188 | 189 | return false; 190 | } 191 | 192 | /** 193 | * Pick task from queue 194 | * 195 | * @param $timeout integer $timeout If given specifies number of seconds to wait for a job, '0' returns immediately 196 | * @return bool|array [id, payload] 197 | */ 198 | public function pickTask() 199 | { 200 | if ($this->connected) { 201 | try { 202 | $result = $this->client->reserve($this->workTimeout); 203 | if (is_array($result)) { 204 | return [$result['id'], $result['body'], []]; 205 | } 206 | } catch (Throwable $e) { 207 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 208 | } 209 | } 210 | 211 | return false; 212 | } 213 | 214 | /** 215 | * Pick many tasks from queue 216 | * 217 | * @param int $max maximum number of tasks to reserve 218 | * @param int $waitForJob should we try and wait for N seconds for job to be available, default not to wait 219 | * 220 | * @return bool|array of [id, payload] 221 | */ 222 | public function pickTasks($max, $waitForJob = 0) 223 | { 224 | if ($this->connected) { 225 | try { 226 | $result = []; 227 | for ($i = 0; $i < $max; $i++) { 228 | /** 229 | * Pick a task or return immediattely if no (more) tasks available 230 | */ 231 | $taskreserve = $this->client->reserve($waitForJob); 232 | if (is_array($taskreserve)) { 233 | $result[] = [$taskreserve['id'], $taskreserve['body']]; 234 | } else { 235 | break; 236 | } 237 | } 238 | 239 | return $result; 240 | } catch (Throwable $e) { 241 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 242 | } 243 | } 244 | 245 | return false; 246 | } 247 | 248 | /** 249 | * Put task into queue 250 | * 251 | * @param string $data The job body. 252 | * @return int|bool `false` on otherwise an integer indicating 253 | * the job id. 254 | */ 255 | public function putTask($body, $params = []) 256 | { 257 | if ($this->connected) { 258 | try { 259 | $priority = self::PRIORITY_DEFAULT; 260 | $readywait = 0; 261 | $jobttr = self::JOBTTR_DEFAULT; 262 | 263 | if (isset($params[self::PARAM_PRIORITY])) { 264 | $priority = $params[self::PARAM_PRIORITY]; 265 | } 266 | 267 | if (isset($params[self::PARAM_READYWAIT])) { 268 | $readywait = $params[self::PARAM_READYWAIT]; 269 | } 270 | 271 | if (isset($params[self::PARAM_JOBTTR])) { 272 | $jobttr = $params[self::PARAM_JOBTTR]; 273 | } 274 | 275 | $result = $this->client->put($priority, $readywait, $jobttr, $body); 276 | 277 | if (false !== $result) { 278 | return (string) $result; 279 | } 280 | } catch (Throwable $e) { 281 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 282 | } 283 | } 284 | 285 | return false; 286 | } 287 | 288 | /** 289 | * After failed work processing 290 | * 291 | */ 292 | public function afterWorkFailed($workId): bool 293 | { 294 | if ($this->connected) { 295 | try { 296 | /** 297 | * Release task back to queue with default priority and 1 second ready-delay 298 | */ 299 | if ($this->client->release($workId, self::PRIORITY_DEFAULT, 1)) { 300 | return true; 301 | } 302 | } catch (Throwable $e) { 303 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 304 | } 305 | } 306 | 307 | return false; 308 | } 309 | 310 | /** 311 | * After successful work processing 312 | * 313 | */ 314 | public function afterWorkSuccess($workId): bool 315 | { 316 | if ($this->connected) { 317 | try { 318 | if ($this->client->delete($workId)) { 319 | return true; 320 | } 321 | } catch (Throwable $e) { 322 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 323 | } 324 | } 325 | 326 | return false; 327 | } 328 | 329 | /** 330 | * Disconnects from queue 331 | * 332 | */ 333 | public function disconnect(): bool 334 | { 335 | if (true === $this->connected) { 336 | try { 337 | $this->client->disconnect(); 338 | $this->connected = false; 339 | 340 | return true; 341 | } catch (Throwable $e) { 342 | $this->logError(self::class . ' adapter ' . __FUNCTION__ . ' exception: ' . $e->getMessage()); 343 | } 344 | } 345 | 346 | return false; 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/Adapter/Beanstalk/Client.php: -------------------------------------------------------------------------------- 1 | true, 35 | 'host' => '127.0.0.1', 36 | 'port' => 11300, 37 | 'timeout' => 1, 38 | 'logger' => null, 39 | ]; 40 | $this->_config = array_merge($defaults, $config); 41 | } 42 | 43 | public function __destruct() 44 | { 45 | if (!empty($this->_config)) { 46 | if ($this->_config['persistent']) { 47 | return true; 48 | } 49 | } 50 | $this->disconnect(); 51 | } 52 | 53 | /** 54 | * Initiates a socket connection to the beanstalk server. The resulting 55 | * stream will not have any timeout set on it. Which means it can wait 56 | * an unlimited amount of time until a packet becomes available. This 57 | * is required for doing blocking reads. 58 | * 59 | * @see \Beanstalk\Client::$_connection 60 | * @see \Beanstalk\Client::reserve() 61 | * @return bool `true` if the connection was established, `false` otherwise. 62 | */ 63 | public function connect(): bool 64 | { 65 | if (isset($this->_io)) { 66 | $this->disconnect(); 67 | } 68 | 69 | $connectionTimeout = 1; 70 | if ($this->_config['timeout']) { 71 | $connectionTimeout = $this->_config['timeout']; 72 | } 73 | 74 | try { 75 | $this->_io = new IO\StreamIO( 76 | $this->_config['host'], 77 | $this->_config['port'], 78 | $connectionTimeout, 79 | self::IO_TIMEOUT, 80 | null, 81 | true, 82 | $this->_config['persistent'] 83 | ); 84 | $this->connected = true; 85 | } catch (Throwable $ex) { 86 | $this->_error($ex->getCode() . ': ' . $ex->getMessage()); 87 | } 88 | 89 | return $this->connected; 90 | } 91 | 92 | /** 93 | * @param null $timeout not specifying timeout may result in undetected connection issue and infinite waiting time 94 | * 95 | * @return array|false 96 | */ 97 | public function reserve($timeout = null) 98 | { 99 | /** 100 | * Writing will throw Exception on timeout --> 101 | */ 102 | if (isset($timeout)) { 103 | $streamTimeout = $timeout + self::IO_TIMEOUT; 104 | $this->_io->stream_set_timeout($streamTimeout); 105 | $this->_write(sprintf('reserve-with-timeout %d', $timeout)); 106 | } else { 107 | $streamTimeout = PHP_INT_MAX; 108 | /** 109 | * Dangerously long waiting time, also pretty optimistic to expect an answer w/o timeout, 110 | * NOT RECOMMENDED use reserve w/o timeout 111 | */ 112 | $this->_io->stream_set_timeout($streamTimeout); 113 | $this->_write('reserve'); 114 | } 115 | /** 116 | * Writing will throw Exception on timeout <-- 117 | */ 118 | 119 | /** 120 | * Read mig 121 | */ 122 | $readio = $this->_read(); 123 | $status = strtok($readio, ' '); 124 | 125 | /** 126 | * Every subsequent call to strtok only needs the token to use, 127 | * as it keeps track of where it is in the current string 128 | * @see http://php.net/manual/en/function.strtok.php 129 | */ 130 | 131 | /** 132 | * is the job id -- an integer unique to this job in this instance of 133 | * beanstalkd. 134 | */ 135 | $jobid = intval(strtok(' ')); 136 | 137 | /** 138 | * is an integer indicating the size of the job body, not including 139 | * the trailing "\r\n" 140 | */ 141 | $bodyN = intval(strtok(' ')); 142 | 143 | /** 144 | * Read is blocking 145 | * 146 | * we write and then we try to read from the stream until: TIMEOUT is reached OR payload received 147 | * then restore general timeout 148 | */ 149 | $this->_io->stream_set_timeout(self::IO_TIMEOUT); 150 | 151 | switch ($status) { 152 | case 'RESERVED': 153 | return [ 154 | 'id' => $jobid, 155 | 'body' => $this->_read($bodyN), 156 | ]; 157 | 158 | break; 159 | /** 160 | * If a non-negative timeout was specified and the timeout exceeded before a job 161 | * became available, or if the client's connection is half-closed, the server 162 | * will respond with TIMED_OUT. 163 | */ 164 | case 'TIMED_OUT': 165 | if (!isset($timeout)) { 166 | $this->_error(__FUNCTION__ . " status = '" . $status . "', timeout=" . $streamTimeout); 167 | } 168 | 169 | /** 170 | * Expected behaviour, 171 | * we waited TIMEOUT period and no payload was received, basicly a HEARTBEAT 172 | */ 173 | return false; 174 | 175 | break; 176 | case 'DEADLINE_SOON': 177 | default: 178 | $this->_error(__FUNCTION__ . " status = '" . $status . "', timeout=" . $streamTimeout); 179 | 180 | return false; 181 | } 182 | } 183 | 184 | public function disconnect() 185 | { 186 | if ($this->connected) { 187 | try { 188 | $this->_write('quit'); 189 | //$this->_io->close(); 190 | } catch (Throwable $ex) { 191 | $this->_error(__FUNCTION__ . " error: " . $ex->getMessage()); 192 | } 193 | } 194 | $this->_io = null; 195 | $this->connected = false; 196 | 197 | return $this->connected; 198 | } 199 | 200 | /** 201 | * Gives statistical information about the specified tube if it exists. 202 | * 203 | * @param string $tube Name of the tube. 204 | * @return string|bool `false` on error otherwise a string with a yaml formatted dictionary. 205 | */ 206 | public function statsTube($tube) 207 | { 208 | $cmd = sprintf('stats-tube %s', $tube); 209 | $this->_write($cmd); 210 | 211 | return $this->_statsRead($cmd); 212 | } 213 | 214 | protected function _write($data) 215 | { 216 | if (!$this->connected) { 217 | $message = 'No connecting found while writing data to socket.'; 218 | 219 | throw new RuntimeException($message); 220 | } 221 | $this->_io->write($data . "\r\n"); 222 | 223 | return strlen($data); 224 | } 225 | 226 | /** 227 | * @phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh 228 | */ 229 | protected function _read($length = null) 230 | { 231 | if (!$this->connected) { 232 | $message = 'No connection found while reading data from socket.'; 233 | 234 | throw new RuntimeException($message); 235 | } 236 | 237 | if ($length) { 238 | try { 239 | /** 240 | * +2 for trailing "\r\n" 241 | */ 242 | $packet = $this->_io->stream_get_contents($length + 2); 243 | if (false === $packet) { 244 | /** 245 | * stream_get_contents returns false on failure 246 | */ 247 | throw new RuntimeException('Failed to io.stream_get_contents on ' . __FUNCTION__); 248 | } 249 | if ($packet) { 250 | $packet = rtrim($packet, "\r\n"); 251 | } 252 | } catch (IO\Exception\TimeoutException $ex) { 253 | if (IO\StreamIO::READ_EOF_CODE === $ex->getCode()) { 254 | return false; 255 | } 256 | 257 | throw new RuntimeException($ex->getMessage(), $ex->getCode()); 258 | } 259 | } else { 260 | /** 261 | * The number of bytes to read from the handle 262 | */ 263 | $packet = $this->_io->stream_get_line(32768, "\r\n"); 264 | if (false === $packet) { 265 | /** 266 | * stream_get_line can also return false on failure 267 | */ 268 | throw new RuntimeException('Failed to io.stream_get_line on ' . __FUNCTION__); 269 | } 270 | } 271 | 272 | return $packet; 273 | } 274 | 275 | protected function _statsRead($readWhat = '') 276 | { 277 | $status = strtok($this->_read(), ' '); 278 | 279 | switch ($status) { 280 | case 'OK': 281 | $data = $this->_read((int) strtok(' ')); 282 | 283 | return $this->_decode($data); 284 | default: 285 | $this->_error(__FUNCTION__ . ' after ' . $readWhat . ' got ' . $status . ' expected OK'); 286 | 287 | return false; 288 | } 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /src/Adapter/DynamoSQS.php: -------------------------------------------------------------------------------- 1 | TTL Expire -> DynamoDB Streams -> AWS Lambda -> SQS 35 | * 36 | * @package BackQ\Adapter 37 | */ 38 | class DynamoSQS extends AbstractAdapter 39 | { 40 | /** 41 | * Some identifier whatever it is 42 | */ 43 | public const PARAM_MESSAGE_ID = 'msgid'; 44 | 45 | protected const API_VERSION_DYNAMODB = '2012-08-10'; 46 | protected const API_VERSION_SQS = '2012-11-05'; 47 | 48 | /** 49 | * Average expected delay from DynamoDB streams to expire items whose TTL was reached 50 | * (12 minutes) 51 | */ 52 | protected const DYNAMODB_ESTIMATED_DELAY = 720; 53 | 54 | /** 55 | * DynamoDB won't process items with a TTL older than 5 years 56 | */ 57 | protected const DYNAMODB_MAXIMUM_PROCESSABLE_TIME = '5 years'; 58 | 59 | protected const TIMEOUT_VISIBILITY_MIN = 10; 60 | 61 | protected ?DynamoDbClient $dynamoDBClient = null; 62 | 63 | protected ?SqsClient $sqsClient = null; 64 | 65 | protected ?string $dynamoDbTableName = null; 66 | 67 | protected ?string $sqsQueueURL = null; 68 | 69 | /** 70 | * AWS DynamoDB API Key 71 | */ 72 | protected string $apiKey = ''; 73 | 74 | /** 75 | * AWS DynamoDB API Secret 76 | */ 77 | protected string $apiSecret = ''; 78 | 79 | /** 80 | * AWS DynamoDB API Region 81 | */ 82 | protected string $apiRegion = ''; 83 | 84 | /** 85 | * AWS Account ID 86 | */ 87 | protected string $apiAccountId = ''; 88 | 89 | /** 90 | * Timeout for receiveMessage from SQS command (Long polling) 91 | * 92 | */ 93 | private int $workTimeout = 5; 94 | 95 | private $maxNumberOfMessages = 1; 96 | 97 | public function __construct(string $apiAccountId, string $apiKey, string $apiSecret, string $apiRegion) 98 | { 99 | $this->apiKey = $apiKey; 100 | $this->apiSecret = $apiSecret; 101 | $this->apiRegion = $apiRegion; 102 | $this->apiAccountId = $apiAccountId; 103 | } 104 | 105 | /** 106 | */ 107 | public function connect(): bool 108 | { 109 | $arguments = ['version' => self::API_VERSION_DYNAMODB, 110 | 'region' => $this->apiRegion, 111 | 'credentials' => ['key' => $this->apiKey, 112 | 'secret' => $this->apiSecret]]; 113 | 114 | $this->dynamoDBClient = new DynamoDbClient($arguments); 115 | 116 | $arguments['version'] = self::API_VERSION_SQS; 117 | $this->sqsClient = new SqsClient($arguments); 118 | 119 | return true; 120 | } 121 | 122 | /** 123 | */ 124 | public function disconnect(): bool 125 | { 126 | $this->dynamoDBClient = null; 127 | $this->dynamoDbTableName = null; 128 | $this->sqsClient = null; 129 | $this->sqsQueueURL = null; 130 | 131 | return true; 132 | } 133 | 134 | /** 135 | * @param string $queue 136 | */ 137 | public function bindRead($queue): bool 138 | { 139 | $this->sqsQueueURL = $this->generateSqsEndpointUrl($queue); 140 | 141 | return true; 142 | } 143 | 144 | /** 145 | * @param string $sqsURL 146 | */ 147 | public function bindWrite($queue): bool 148 | { 149 | $this->dynamoDbTableName = $queue; 150 | 151 | return true; 152 | } 153 | 154 | public function pickTask() 155 | { 156 | $this->logDebug(__FUNCTION__); 157 | 158 | $sqs = $this->sqsClient; 159 | assert($sqs instanceof SqsClient); 160 | if (!$sqs) { 161 | return false; 162 | } 163 | 164 | /** 165 | * @see https://docs.aws.amazon.com/aws-sdk-php/v2/api/class-Aws.Sqs.SqsClient.html#_receiveMessage 166 | */ 167 | $result = null; 168 | try { 169 | $result = $sqs->receiveMessage(['AttributeNames' => ['All'], 170 | 'MaxNumberOfMessages' => $this->maxNumberOfMessages, 171 | 'MessageAttributeNames' => ['All'], 172 | 'QueueUrl' => $this->sqsQueueURL, 173 | 'WaitTimeSeconds' => (int) $this->workTimeout, 174 | 'VisibilityTimeout' => $this->calculateVisibilityTimeout()]); 175 | } catch (AwsException $e) { 176 | $this->logError($e->getMessage()); 177 | } 178 | 179 | if ($result && $result->hasKey('Messages') && count($result->get('Messages')) > 0) { 180 | $messagePayload = ($result->get('Messages')[0]); 181 | 182 | $messageBody = @json_decode($messagePayload['Body'], true); 183 | $itemPayload = null; 184 | if (is_array($messageBody)) { 185 | $item = QueueTableRow::fromArray($messageBody); 186 | 187 | if ($item) { 188 | $itemPayload = $item->getPayload(); 189 | } else { 190 | $this->logError(__FUNCTION__ . ' Invalid received message body'); 191 | } 192 | } else { 193 | $this->logError(__FUNCTION__ . ' Unexpected data format on message body'); 194 | } 195 | 196 | $messageId = $messagePayload['ReceiptHandle']; 197 | 198 | return [$messageId, $itemPayload]; 199 | } 200 | 201 | return false; 202 | } 203 | 204 | public function putTask($body, $params = []) 205 | { 206 | $this->logDebug(__FUNCTION__); 207 | 208 | if (!$this->dynamoDBClient) { 209 | return false; 210 | } 211 | 212 | $readyTime = time(); 213 | if (isset($params[self::PARAM_READYWAIT])) { 214 | $readyTime += $this->getEstimatedTTL($params[self::PARAM_READYWAIT]); 215 | } 216 | 217 | /** 218 | * Make sure the TTL can be processed by Dynamo 219 | */ 220 | $minTTL = strtotime('-' . self::DYNAMODB_MAXIMUM_PROCESSABLE_TIME); 221 | if ($readyTime <= $minTTL) { 222 | throw new InvalidArgumentException('Cannot process item with TTL: ' . $readyTime); 223 | } 224 | 225 | $msgid = crc32(getmypid() . gethostname()); 226 | if (isset($params[self::PARAM_MESSAGE_ID])) { 227 | $msgid = $params[self::PARAM_MESSAGE_ID]; 228 | } 229 | 230 | $item = new QueueTableRow($body, $readyTime, $msgid); 231 | try { 232 | $response = $this->dynamoDBClient->putItem(['Item' => $item->toArray(), 233 | 'TableName' => $this->dynamoDbTableName]); 234 | if ($response && 235 | isset($response['@metadata']['statusCode']) && 236 | 200 === $response['@metadata']['statusCode']) { 237 | $this->logDebug(__FUNCTION__ . ' success'); 238 | 239 | return true; 240 | } 241 | } catch (DynamoDbException $e) { 242 | $this->logError(__FUNCTION__ . ' service failed: ' . $e->getMessage()); 243 | } 244 | 245 | return false; 246 | } 247 | 248 | /** 249 | * @param $workId 250 | */ 251 | public function afterWorkSuccess($workId): bool 252 | { 253 | if ($this->sqsClient) { 254 | $sqs = $this->sqsClient; 255 | assert($sqs instanceof SqsClient); 256 | try { 257 | $sqs->deleteMessage(['QueueUrl' => $this->sqsQueueURL, 'ReceiptHandle' => $workId]); 258 | 259 | return true; 260 | } catch (AwsException $e) { 261 | $this->logError($e->getMessage()); 262 | } 263 | } 264 | 265 | return true; 266 | } 267 | 268 | /** 269 | * @phpcs:disable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter 270 | */ 271 | public function afterWorkFailed($workId): bool 272 | { 273 | /** 274 | * Could call SQS ChangeMessageVisibility but why bother, the message will come back after 275 | * visibility timeout expires (reserved time to process job) 276 | */ 277 | return true; 278 | } 279 | 280 | public function ping(): bool 281 | { 282 | return true; 283 | } 284 | 285 | /** 286 | * @phpcs:disable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter 287 | */ 288 | public function hasWorkers($queue): bool 289 | { 290 | /** 291 | * @todo implement using SQS or DynamoDB as separate table/lock 292 | */ 293 | return true; 294 | } 295 | 296 | /** 297 | * The duration (in seconds) for which the call waits for a message to arrive in the queue before returning. 298 | * If a message is available, the call returns sooner than $seconds seconds. 299 | * If no messages are available and the wait time expires, 300 | * the call returns successfully with an empty list of messages. 301 | * 302 | * @param int|null $seconds 303 | * @return null 304 | */ 305 | public function setWorkTimeout(?int $seconds = null) 306 | { 307 | $this->workTimeout = $seconds; 308 | 309 | return null; 310 | } 311 | 312 | /** 313 | * Expected SQS Queue URL 314 | * 315 | * https://sqs.REGION.amazonaws.com/XXXXXXXX/QUEUENAME 316 | * 317 | * @param string $queue 318 | */ 319 | private function generateSqsEndpointUrl(string $queue): string 320 | { 321 | return 'https://sqs.' . $this->apiRegion . '.amazonaws.com/' . $this->apiAccountId . '/' . $queue; 322 | } 323 | 324 | private function calculateVisibilityTimeout(): int 325 | { 326 | /** 327 | * How much time we estimate it takes to process the picked results 328 | */ 329 | return max($this->workTimeout * 4, self::TIMEOUT_VISIBILITY_MIN); 330 | } 331 | 332 | /** 333 | * Calculate a TTL value based on the average delay from 'expired' DynamoDB Stream items 334 | * 335 | * @param int $expectedTTL 336 | * 337 | */ 338 | private function getEstimatedTTL(int $expectedTTL): int 339 | { 340 | $estimatedTTL = $expectedTTL; 341 | if ($expectedTTL >= self::DYNAMODB_ESTIMATED_DELAY) { 342 | $estimatedTTL -= self::DYNAMODB_ESTIMATED_DELAY; 343 | } 344 | 345 | return $estimatedTTL; 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /src/Adapter/Fcm.php: -------------------------------------------------------------------------------- 1 | setApiKey($apiKey); 46 | } 47 | 48 | /** 49 | * Send Message 50 | */ 51 | public function send(Zend_Mobile_Push_Message_Abstract $message): Zend_Http_Response 52 | { 53 | if (!$message->validate()) { 54 | throw new Zend_Mobile_Push_Exception('The message is not valid.'); 55 | } 56 | 57 | /** 58 | * Customize client --> 59 | */ 60 | $httpadapter = ['adapter' => 'Zend_Http_Client_Adapter_Curl', 61 | 'timeout' => $this->connectTimeout, 62 | 'request_timeout' => $this->actionTimeout, 63 | 'maxredirects' => $this->maxredirects, 64 | /** 65 | * Any options except Zend_Http_Client_Adapter_Curl._invalidOverwritableCurlOptions 66 | */ 67 | 'curloptions' => [CURLOPT_TIMEOUT => $this->actionTimeout, 68 | CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, 69 | CURLOPT_FRESH_CONNECT => 0, 70 | CURLOPT_FORBID_REUSE => 0, 71 | CURLOPT_NOSIGNAL => true, 72 | CURLOPT_NOPROGRESS => true, 73 | //CURLOPT_TCP_FASTOPEN => true 74 | ]]; 75 | if (defined('CURL_SSLVERSION_TLSv1_2')) { 76 | $httpadapter['curloptions'][CURLOPT_SSLVERSION] = CURL_SSLVERSION_TLSv1_2; 77 | } 78 | 79 | $this->setHttpClient(new Zend_Http_Client(null, $httpadapter)); 80 | /** 81 | * Customize client <-- 82 | */ 83 | 84 | $this->connect(); 85 | 86 | $client = $this->getHttpClient(); 87 | $client->setUri(self::SERVER_URI); 88 | $client->setHeaders('Authorization', 'key=' . $this->getApiKey()); 89 | 90 | $client->setRawData($message->toJson(), 'application/json'); 91 | $response = $client->request('POST'); 92 | $this->close(); 93 | 94 | // switch ($response->getStatus()) 95 | // { 96 | // case 500: 97 | // require_once 'Zend/Mobile/Push/Exception/ServerUnavailable.php'; 98 | // throw new Zend_Mobile_Push_Exception_ServerUnavailable('The server encountered an internal error, try again'); 99 | // break; 100 | // case 503: 101 | // require_once 'Zend/Mobile/Push/Exception/ServerUnavailable.php'; 102 | // throw new Zend_Mobile_Push_Exception_ServerUnavailable('The server was unavailable, check Retry-After header'); 103 | // break; 104 | // case 401: 105 | // require_once 'Zend/Mobile/Push/Exception/InvalidAuthToken.php'; 106 | // throw new Zend_Mobile_Push_Exception_InvalidAuthToken('There was an error authenticating the sender account'); 107 | // break; 108 | // case 400: 109 | // require_once 'Zend/Mobile/Push/Exception/InvalidPayload.php'; 110 | // throw new Zend_Mobile_Push_Exception_InvalidPayload('The request could not be parsed as JSON or contains invalid fields'); 111 | // break; 112 | // } 113 | return $response; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Adapter/IO/AbstractIO.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | abstract public function stream_get_contents(int $length): void; 29 | 30 | abstract public function stream_get_line(int $length, string $delimiter): void; 31 | 32 | abstract public function stream_set_timeout($read_write_timeout): void; 33 | /** 34 | * Advanced functions <-- 35 | */ 36 | } 37 | -------------------------------------------------------------------------------- /src/Adapter/IO/Exception/IOException.php: -------------------------------------------------------------------------------- 1 | sock = null; 96 | $this->persistent = (bool) $persistent; 97 | $triesLeft = $this->connAttempts; 98 | 99 | while (!$this->sock && $triesLeft > 0) { 100 | if ($context) { 101 | $remote = sprintf('tls://%s:%s/%s', $host, $port, strval($persistent)); 102 | $this->sock = $persistent ? @stream_socket_client( 103 | $remote, 104 | $errno, 105 | $errstr, 106 | $connection_timeout, 107 | STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT, 108 | $context 109 | ) : @stream_socket_client( 110 | $remote, 111 | $errno, 112 | $errstr, 113 | $connection_timeout, 114 | STREAM_CLIENT_CONNECT, 115 | $context 116 | ); 117 | } else { 118 | $remote = sprintf('tcp://%s:%s/%s', $host, $port, strval($persistent)); 119 | $this->sock = $persistent ? @stream_socket_client( 120 | $remote, 121 | $errno, 122 | $errstr, 123 | $connection_timeout, 124 | STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT 125 | ) : @stream_socket_client($remote, $errno, $errstr, $connection_timeout, STREAM_CLIENT_CONNECT); 126 | } 127 | if (!$this->sock) { 128 | $triesLeft--; 129 | usleep($this->connRetryIntervalMs * 1000); 130 | } 131 | } 132 | 133 | if (!$this->sock) { 134 | throw new RuntimeException("Error Connecting to server($errno): $errstr "); 135 | } 136 | 137 | if (null !== $read_write_timeout) { 138 | if (!stream_set_timeout($this->sock, $read_write_timeout)) { 139 | throw new Exception("Timeout (stream_set_timeout) could not be set"); 140 | } 141 | } 142 | 143 | /** 144 | * Manually set blocking & write buffer settings and make sure they are successfuly set 145 | * Use non-blocking as we dont want to stuck waiting for socket data while fread() w/o timeout 146 | */ 147 | if (!stream_set_blocking($this->sock, $blocking)) { 148 | throw new Exception("Blocking could not be set"); 149 | } 150 | 151 | $rbuff = stream_set_read_buffer($this->sock, 0); 152 | if (!(0 === $rbuff)) { 153 | throw new Exception("Read buffer could not be set"); 154 | } 155 | 156 | /** 157 | * ! this is not reliably returns success (0) 158 | * ! but default buffer is pretty high (few Kb), so will not affect sending single small pushes 159 | * 160 | * Output using fwrite() is normally buffered at 8K. 161 | * This means that if there are two processes wanting to write to the same output stream (a file), 162 | * each is paused after 8K of data to allow the other to write. 163 | * 164 | * Ensures that all writes with fwrite() are completed 165 | * before other processes are allowed to write to that output stream 166 | */ 167 | stream_set_write_buffer($this->sock, 0); 168 | 169 | /** 170 | * Set small chunk size (default=4096/8192) 171 | * Setting this to small values (100bytes) still does NOT help detecting feof() 172 | */ 173 | stream_set_chunk_size($this->sock, 1024); 174 | } 175 | 176 | public function read($n) 177 | { 178 | $info = stream_get_meta_data($this->sock); 179 | 180 | if ($info['eof'] || @feof($this->sock)) { 181 | throw new TimeoutException('Error reading data. Socket connection EOF', self::READ_EOF_CODE); 182 | } 183 | 184 | if ($info['timed_out']) { 185 | throw new TimeoutException('Error reading data. Socket connection TIME OUT', self::READ_TIME_CODE); 186 | } 187 | 188 | /** 189 | * @todo add custom error handler as in write() 190 | */ 191 | 192 | $tries = self::FREAD_0_TRIES; 193 | $fread_result = ''; 194 | while (!@feof($this->sock) && strlen($fread_result) < $n) { 195 | /** 196 | * Up to $n number of bytes read. 197 | */ 198 | $fdata = @fread($this->sock, $n); 199 | if (false === $fdata) { 200 | throw new RuntimeException("Failed to fread() from socket", self::READ_ERR_CODE); 201 | } 202 | $fread_result .= $fdata; 203 | 204 | if (!$fdata) { 205 | $tries--; 206 | } 207 | 208 | if ($tries <= 0) { 209 | /** 210 | * Nothing to read 211 | */ 212 | break; 213 | } 214 | } 215 | 216 | return $fread_result; 217 | } 218 | 219 | public function stream_set_timeout($read_write_timeout): void 220 | { 221 | if (!stream_set_timeout($this->sock, $read_write_timeout)) { 222 | throw new Exception("Timeout (stream_set_timeout) could not be set"); 223 | } 224 | } 225 | 226 | public function write($data): void 227 | { 228 | // get status of socket to determine whether or not it has timed out 229 | $info = @stream_get_meta_data($this->sock); 230 | 231 | if ($info['eof'] || @feof($this->sock)) { 232 | throw new TimeoutException("Error sending data. Socket connection EOF"); 233 | } 234 | 235 | if ($info['timed_out']) { 236 | throw new TimeoutException("Error sending data. Socket connection TIMED OUT"); 237 | } 238 | 239 | /** 240 | * fwrite throws NOTICE error on broken pipe 241 | * send of N bytes failed with errno=32 Broken pipe or errno=2 SSL Broken pipe 242 | * 243 | * PHP Writes are buffered and stream_set_write_buffer() doesnt work correctly 244 | * with non-blocking streams, so even if fwrite() reports data is written 245 | * it doesnt mean system actualy sent the data and we caught feof() 246 | * 247 | * This happens when we didnt waited long enough for APNS to return error, and 248 | * continued sending the data, we will catch feof() only after some time 249 | */ 250 | $oreporting = error_reporting(E_ALL); 251 | set_error_handler(static function ($severity, $text): void { 252 | //$ohandler = set_error_handler(function($severity, $text) { 253 | throw new RuntimeException('fwrite() error (' . $severity . '): ' . $text); 254 | }); 255 | 256 | $tries = self::WRITE_0_TRIES; 257 | $len = strlen($data); 258 | 259 | try { 260 | for ($written = 0; $written < $len; true) { 261 | $fwrite = fwrite($this->sock, substr($data, $written)); 262 | fflush($this->sock); 263 | $written += intval($fwrite); 264 | 265 | if (false === $fwrite || (feof($this->sock) && $written < $len)) { 266 | /** 267 | * This bugged on 7.0.4 and maybe other versions 268 | * @see https://bugs.php.net/bug.php?id=71907 269 | * Actually returns int(0) instead of FALSE 270 | * 271 | * Some writes execute remote connection close, then its not uncommon to see 272 | * connection being closed after write is successful 273 | */ 274 | throw new RuntimeException("Failed to fwrite() to socket: " . ($len - $written) . 'bytes left'); 275 | } 276 | 277 | if (0 === $fwrite) { 278 | $tries--; 279 | } 280 | 281 | if ($tries <= 0) { 282 | throw new RuntimeException('Failed to write to socket after ' . self::WRITE_0_TRIES . ' retries'); 283 | } 284 | } 285 | 286 | /** 287 | * Restore original handlers after normal operations 288 | */ 289 | error_reporting($oreporting); 290 | //set_error_handler($ohandler); 291 | restore_error_handler(); 292 | } catch (Throwable $t) { 293 | /** 294 | * Restore original handlers if exception happens 295 | */ 296 | error_reporting($oreporting); 297 | //set_error_handler($ohandler); 298 | restore_error_handler(); 299 | 300 | throw new RuntimeException($t->getMessage(), $t->getCode()); 301 | } 302 | } 303 | 304 | public function close(): void 305 | { 306 | if (is_resource($this->sock)) { 307 | stream_socket_shutdown($this->sock, STREAM_SHUT_RDWR); 308 | fclose($this->sock); 309 | } 310 | $this->sock = null; 311 | } 312 | 313 | /** 314 | * Returns a string of up to length bytes read, If an error occurs, returns false 315 | * 316 | * @param int $length 317 | * @param string $delimiter 318 | * 319 | * @throws TimeoutException 320 | * @return string|false 321 | */ 322 | public function stream_get_line(int $length, string $delimiter = "\r\n") 323 | { 324 | $info = stream_get_meta_data($this->sock); 325 | 326 | if ($info['eof'] || feof($this->sock)) { 327 | throw new TimeoutException('Error reading data. Socket connection EOF', self::READ_EOF_CODE); 328 | } 329 | 330 | if ($info['timed_out']) { 331 | throw new TimeoutException('Error reading data. Socket connection TIME OUT', self::READ_TIME_CODE); 332 | } 333 | /** 334 | * Reading ends when length bytes have been read, 335 | * when the string specified by ending is found (which is not included in the return value), 336 | * or on EOF (whichever comes first). 337 | */ 338 | $data = stream_get_line($this->sock, $length, $delimiter); 339 | if (false === $data && feof($this->sock)) { 340 | throw new TimeoutException('Failed stream_get_line. Socket EOF detected', self::READ_EOF_CODE); 341 | } 342 | 343 | return $data; 344 | } 345 | 346 | /** 347 | * Reads remainder of a stream into a string, return a string or false on failure. 348 | * 349 | * @param int $length 350 | * 351 | * @throws TimeoutException 352 | * @return string|false 353 | */ 354 | public function stream_get_contents(int $length) 355 | { 356 | $info = stream_get_meta_data($this->sock); 357 | 358 | if ($info['eof'] || feof($this->sock)) { 359 | throw new TimeoutException('Error reading data. Socket connection EOF', self::READ_EOF_CODE); 360 | } 361 | 362 | if ($info['timed_out']) { 363 | throw new TimeoutException('Error reading data. Socket connection TIME OUT', self::READ_TIME_CODE); 364 | } 365 | 366 | return stream_get_contents($this->sock, $length); 367 | } 368 | 369 | public function selectWrite($sec, $usec) 370 | { 371 | $read = null; 372 | $write = [$this->sock]; 373 | $except = null; 374 | 375 | return stream_select($read, $write, $except, $sec, $usec); 376 | } 377 | 378 | public function selectRead($sec, $usec) 379 | { 380 | $read = [$this->sock]; 381 | $write = null; 382 | $except = null; 383 | 384 | return stream_select($read, $write, $except, $sec, $usec); 385 | } 386 | 387 | public function isSocketReady(): bool 388 | { 389 | $info = stream_get_meta_data($this->sock); 390 | 391 | return $info['eof'] || feof($this->sock) || $info['timed_out']; 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /src/Adapter/Redis/App.php: -------------------------------------------------------------------------------- 1 | redis, 39 | $config['queue'], 40 | $config['connection'] ?? $this->connection, 41 | $config['retry_after'] ?? null, 42 | $config['block_for'] ?? null 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Adapter/Redis/Queue.php: -------------------------------------------------------------------------------- 1 | =N seconds without being deleted (successful execution = delete()) 25 | * 26 | * @see https://laravel.com/docs/5.7/queues#retrying-failed-jobs 27 | */ 28 | protected ?int $retryAfter = null; 29 | 30 | /** 31 | * The maximum number of seconds to block for a job. 32 | * 33 | */ 34 | protected ?int $blockFor = null; 35 | 36 | /** 37 | * @param int|null $seconds 38 | */ 39 | public function setBlockFor(?int $seconds): void 40 | { 41 | $this->blockFor = $seconds; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Logger.php: -------------------------------------------------------------------------------- 1 | logFile = $logFile; 31 | } 32 | 33 | public function log($sMessage, $debug = false): void 34 | { 35 | if (!$debug && (false !== strpos($sMessage, 'INFO:') || false !== strstr($sMessage, 'STATUS:'))) { 36 | return; 37 | } 38 | 39 | if ($log_handler = fopen($this->logFile, 'a')) { 40 | fwrite($log_handler, date('Y-m-d H:i:s') . ' - ' . getmypid() . ' - ' . trim($sMessage) . "\n"); 41 | fclose($log_handler); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Message/AbstractMessage.php: -------------------------------------------------------------------------------- 1 | message = $message; 44 | } 45 | 46 | /** 47 | * Takes the data and properly assigns it to a json encoded array to wrap 48 | * a subset of Gcm format into a customContent key 49 | * 50 | */ 51 | public function getMessage(): string 52 | { 53 | return json_encode($this->message); 54 | } 55 | 56 | /** 57 | * Returns the Amazon Resource Name for the endpoint a message should be published to 58 | * 59 | */ 60 | public function getTargetArn(): string 61 | { 62 | return $this->targetArn; 63 | } 64 | 65 | /** 66 | * Sets up the Resource Identifier for the endpoint that a message will be published to 67 | * 68 | * @param string $targetArn 69 | */ 70 | public function setTargetArn(string $targetArn): void 71 | { 72 | $this->targetArn = $targetArn; 73 | } 74 | 75 | /** 76 | * Gets specific attributes to complete a Publish operation to an endpoint 77 | * 78 | * @return array 79 | */ 80 | public function getAttributes(): array 81 | { 82 | return $this->attributes; 83 | } 84 | 85 | /** 86 | * Sets attributes specific to different platforms in order to publish a message 87 | * 88 | * @param array $attrs 89 | */ 90 | public function setAttributes(array $attrs): void 91 | { 92 | $this->attributes = $attrs; 93 | } 94 | 95 | public function getMessageStructure(): string 96 | { 97 | return $this->messageStructure; 98 | } 99 | 100 | public function setMessageStructure(string $structure): void 101 | { 102 | $this->messageStructure = $structure; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Message/Amazon/SNS/Application/PlatformEndpoint/PublishMessageInterface.php: -------------------------------------------------------------------------------- 1 | attributes; 44 | } 45 | 46 | /** 47 | * Sets attributes specific to different platforms in order to publish a message 48 | * 49 | * @param array $attrs 50 | */ 51 | public function setAttributes(array $attrs): void 52 | { 53 | $this->attributes = $attrs; 54 | } 55 | 56 | /** 57 | * Get the resource name for the Application Platform where an endpoint 58 | * where an endpoint will be saved 59 | */ 60 | public function getApplicationArn(): string 61 | { 62 | return $this->applicationArn; 63 | } 64 | 65 | /** 66 | * Sets up the Resource Number for a Platform Application 67 | * @param $appArn 68 | */ 69 | public function setApplicationArn(string $appArn): void 70 | { 71 | $this->applicationArn = $appArn; 72 | } 73 | 74 | /** 75 | * Gets the token or identifier for the device to register 76 | */ 77 | public function getToken(): string 78 | { 79 | return $this->token; 80 | } 81 | 82 | /** 83 | * Adds a unique identifier created by the notification service for the app on a device 84 | * @param $token 85 | */ 86 | public function addToken(string $token): void 87 | { 88 | $this->token = $token; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Message/Amazon/SNS/Application/PlatformEndpoint/RegisterMessageInterface.php: -------------------------------------------------------------------------------- 1 | endpointArn; 29 | } 30 | 31 | /** 32 | * Sets up an Amazon Resource Name from an endpoint to remove 33 | * 34 | * @param string $arn 35 | */ 36 | public function setEndpointArn(string $arn): void 37 | { 38 | $this->endpointArn = $arn; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Message/Amazon/SNS/Application/PlatformEndpoint/RemoveMessageInterface.php: -------------------------------------------------------------------------------- 1 | function = $function; 23 | } 24 | 25 | /** 26 | * Executes the closure for this message 27 | * 28 | * @return mixed 29 | */ 30 | public function execute() 31 | { 32 | $closure = $this->function->getClosure(); 33 | 34 | return $closure(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Message/ConsumeInterface.php: -------------------------------------------------------------------------------- 1 | data = $data; 25 | } 26 | 27 | public function serialize() 28 | { 29 | return serialize($this->data); 30 | } 31 | 32 | public function unserialize($data): void 33 | { 34 | $this->data = unserialize($data); 35 | } 36 | 37 | public function getData() 38 | { 39 | return $this->data; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Message/Guzzle.php: -------------------------------------------------------------------------------- 1 | getUri()->getScheme()) { 35 | $request->withRequestTarget('absolute-form'); 36 | /** 37 | * Preserver HTTPS schema correctly 38 | */ 39 | $this->scheme = 'https'; 40 | } 41 | $this->request = str($request); 42 | } else { 43 | $this->request = $rawRequest; 44 | } 45 | if (empty($this->request)) { 46 | throw new LogicException('Provide either PSR7 request or PSR-7 compatible request body'); 47 | } 48 | } 49 | 50 | /** 51 | */ 52 | public function getRequest(): Request 53 | { 54 | $request = parse_request($this->request); 55 | if (!empty($this->scheme)) { 56 | $uri = $request->getUri(); 57 | $newuri = $uri->withScheme($this->scheme); 58 | 59 | return $request->withUri($newuri); 60 | } 61 | 62 | return $request; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Message/Process.php: -------------------------------------------------------------------------------- 1 | commandline = $commandline; 47 | $this->cwd = $cwd; 48 | $this->env = $env; 49 | $this->input = $input; 50 | $this->timeout = $timeout; 51 | } 52 | 53 | public function getDeadline() 54 | { 55 | return $this->until; 56 | } 57 | 58 | public function setDeadline(int $timestamp): void 59 | { 60 | $this->until = $timestamp; 61 | } 62 | 63 | public function getCommandline() 64 | { 65 | return $this->commandline; 66 | } 67 | 68 | public function getCwd() 69 | { 70 | return $this->cwd; 71 | } 72 | 73 | public function getEnv() 74 | { 75 | return $this->env; 76 | } 77 | 78 | public function getInput() 79 | { 80 | return $this->input; 81 | } 82 | 83 | public function getTimeout() 84 | { 85 | return $this->timeout; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Message/Serialized.php: -------------------------------------------------------------------------------- 1 | message = $message; 30 | $this->publisher = $publisher; 31 | $this->publishOptions = $publishOptions; 32 | } 33 | 34 | /** 35 | * Return publisher to be used for publishing 36 | * 37 | */ 38 | public function getPublisher(): ?AbstractPublisher 39 | { 40 | if ($this->publisher instanceof AbstractPublisher) { 41 | /** 42 | * If publisher is unknown, it will be unserialized as 43 | * __PHP_Incomplete_Class_Name 44 | */ 45 | return $this->publisher; 46 | } 47 | 48 | return null; 49 | } 50 | 51 | /** 52 | * Return options to be used when publisher publishes 53 | * 54 | * @return array 55 | */ 56 | public function getPublishOptions(): array 57 | { 58 | return $this->publishOptions; 59 | } 60 | 61 | /** 62 | * Return message to be (re) published 63 | * 64 | */ 65 | public function getMessage(): ?AbstractMessage 66 | { 67 | return $this->message; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Publisher/AbstractPublisher.php: -------------------------------------------------------------------------------- 1 | adapter = $this->setupAdapter(); 34 | } 35 | 36 | /** 37 | * @param \BackQ\Adapter\AbstractAdapter $adapter 38 | * 39 | */ 40 | public static function getInstance(): AbstractPublisher 41 | { 42 | $class = static::class; 43 | 44 | return new $class(); 45 | } 46 | 47 | /** 48 | * Specify worker queue to push job to 49 | * 50 | */ 51 | public function getQueueName(): string 52 | { 53 | return $this->queueName; 54 | } 55 | 56 | /** 57 | * Set queue a publisher will publish to 58 | * 59 | * @param $string 60 | */ 61 | public function setQueueName(string $string): void 62 | { 63 | $this->queueName = (string) $string; 64 | } 65 | 66 | /** 67 | * Initialize provided adapter 68 | * 69 | */ 70 | public function start(): bool 71 | { 72 | if (true === $this->bind) { 73 | return true; 74 | } 75 | if (true === $this->adapter->connect()) { 76 | if ($this->adapter->bindWrite($this->getQueueName())) { 77 | $this->bind = true; 78 | 79 | return true; 80 | } 81 | } 82 | 83 | return false; 84 | } 85 | 86 | /** 87 | * Check if connection is alive and ready to do the job 88 | */ 89 | public function ready() 90 | { 91 | if ($this->bind) { 92 | return $this->adapter->ping(); 93 | } 94 | } 95 | 96 | /** 97 | * Checks (if possible) if there are workers to work immediately 98 | * 99 | */ 100 | public function hasWorkers(): ?int 101 | { 102 | return $this->adapter->hasWorkers($this->getQueueName()); 103 | } 104 | 105 | /** 106 | * Publish new job 107 | * 108 | * @param mixed $serializable job payload 109 | * @param array $params adapter specific params 110 | * 111 | * @return string|false 112 | */ 113 | public function publish($serializable, $params = []) 114 | { 115 | if (!$this->bind) { 116 | return false; 117 | } 118 | 119 | return $this->adapter->putTask($this->serialize($serializable), $params); 120 | } 121 | 122 | public function finish() 123 | { 124 | if ($this->bind) { 125 | $this->adapter->disconnect(); 126 | $this->bind = false; 127 | 128 | return true; 129 | } 130 | 131 | return false; 132 | } 133 | 134 | protected function serialize($serializable): string 135 | { 136 | return serialize($serializable); 137 | } 138 | 139 | public function __sleep() 140 | { 141 | if ($this->adapter) { 142 | $this->adapter->disconnect(); 143 | } 144 | 145 | $vars = array_keys(get_object_vars($this)); 146 | unset($vars[array_search('adapter', $vars, true)]); 147 | 148 | return array_values($vars); 149 | } 150 | 151 | public function __wakeup(): void 152 | { 153 | $this->adapter = $this->setupAdapter(); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Publisher/Amazon/SNS/Application/PlatformEndpoint/Publish.php: -------------------------------------------------------------------------------- 1 | start(); 44 | $this->logDebug('started'); 45 | $forks = []; 46 | if ($connected) { 47 | try { 48 | $this->logDebug('connected'); 49 | $work = $this->work(); 50 | $this->logDebug('after init work generator'); 51 | 52 | /** 53 | * Until next job maximum 1 zombie process might be hanging, 54 | * we cleanup-up zombies when receiving next job 55 | * @phpcs:disable SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable 56 | */ 57 | foreach ($work as $_ => $payload) { 58 | /** 59 | * Whatever happends, always report successful processing 60 | */ 61 | $processed = true; 62 | 63 | if ($payload) { 64 | $this->logDebug('got some payload: ' . $payload); 65 | 66 | $message = @unserialize($payload); 67 | if (!($message instanceof \BackQ\Message\Process)) { 68 | $run = false; 69 | @error_log('Worker does not support payload of: ' . gettype($message)); 70 | } else { 71 | $run = true; 72 | } 73 | } else { 74 | $this->logDebug('empty loop due to empty payload: ' . var_export($payload, true)); 75 | 76 | $message = null; 77 | $run = false; 78 | } 79 | 80 | try { 81 | if ($run && $message && $deadline = $message->getDeadline()) { 82 | if ($deadline < time()) { 83 | /** 84 | * Do not run any tasks beyond their deadline 85 | */ 86 | $run = false; 87 | } 88 | } 89 | 90 | if ($run) { 91 | if (!$message->isReady()) { 92 | /** 93 | * Message should not be processed yet 94 | */ 95 | $work->send(false); 96 | 97 | continue; 98 | } 99 | 100 | if ($message->isExpired()) { 101 | $work->send(true); 102 | 103 | continue; 104 | } 105 | 106 | /** 107 | * Enclosure in anonymous function 108 | * 109 | * ZOMBIE WARNING 110 | * @see http://stackoverflow.com/questions/29037880/start-a-background-symfony-process-from-symfony-console 111 | * 112 | * All the methods that returns results or use results probed by proc_get_status might be wrong 113 | * @see https://github.com/symfony/symfony/issues/5759 114 | * 115 | * @tip use PHP_BINARY for php path 116 | */ 117 | $run = function () use ($message) { 118 | $this->logDebug('launching ' . $message->getCommandline()); 119 | $cmd = $message->getCommandline(); 120 | /** 121 | * @phpcs:disable SlevomatCodingStandard.ControlStructures.RequireTernaryOperator.TernaryOperatorNotUsed 122 | */ 123 | $timeout = $message->getTimeout() ?? 60; 124 | 125 | if (!is_array($cmd) && is_string($cmd)) { 126 | /** 127 | * @todo remove - deprecated since symfony 4 128 | * @deprecated 129 | */ 130 | $process = Process::fromShellCommandline( 131 | $cmd, 132 | $message->getCwd(), 133 | $message->getEnv(), 134 | $message->getInput(), 135 | /** 136 | * timeout does not really work with async (start) 137 | */ 138 | $timeout 139 | ); 140 | } else { 141 | /** 142 | * Using array of arguments is the recommended way to define commands. 143 | * This saves you from any escaping and allows sending signals seamlessly 144 | * (e.g. to stop processes before completion.): 145 | */ 146 | $process = new Process( 147 | $message->getCommandline(), 148 | $message->getCwd(), 149 | $message->getEnv(), 150 | $message->getInput(), 151 | /** 152 | * timeout does not really work with async (start) 153 | */ 154 | $timeout 155 | ); 156 | } 157 | 158 | /** 159 | * ultimately also disables callbacks 160 | */ 161 | //$process->disableOutput(); 162 | 163 | /** 164 | * Execute call, starts process in the background 165 | * proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $this->env, $this->options); 166 | * 167 | * @throws RuntimeException When process can't be launched 168 | */ 169 | $process->start(); 170 | 171 | return $process; 172 | }; 173 | } 174 | 175 | /** 176 | * Loop over previous forks and gracefully stop/close them, 177 | * doing this before pushing new fork in the pool 178 | */ 179 | if (!empty($forks)) { 180 | foreach ($forks as $f) { 181 | assert($f instanceof Process); 182 | try { 183 | /** 184 | * here we PREVENTs ZOMBIES 185 | * isRunning itself closes the process if its ended (not running) 186 | * use `pstree` to look out for zombies 187 | */ 188 | if ($f->isRunning()) { 189 | /** 190 | * If its still running, check the timeouts 191 | */ 192 | $f->checkTimeout(); 193 | usleep(200000); 194 | } else { 195 | /** 196 | * Only first call of this function return real value, next calls return -1 197 | */ 198 | $ec = $f->getExitCode(); 199 | if ($ec > 0) { 200 | trigger_error( 201 | $f->getCommandLine() . ' [' . $f->getErrorOutput() . '] ' . ' existed with error code ' . $ec, 202 | E_USER_WARNING 203 | ); 204 | $f->clearOutput(); 205 | $f->clearErrorOutput(); 206 | $ec = null; 207 | } 208 | } 209 | } catch (ProcessTimedOutException $e) { 210 | @error_log('Process worker caught ProcessTimedOutException: ' . $e->getMessage()); 211 | } catch (ProcessSignaledException $e) { 212 | @error_log('Process worker caught ProcessSignaledException: ' . $e->getMessage()); 213 | /** 214 | * Child process has been terminated by an uncaught signal. 215 | */ 216 | } 217 | } 218 | } 219 | 220 | if ($run) { 221 | $forks[] = $run(); 222 | } 223 | } catch (Throwable $e) { 224 | /** 225 | * Not caching exceptions, just launching processes async 226 | */ 227 | @error_log('Process worker failed to run: ' . $e->getMessage()); 228 | } 229 | 230 | $this->logDebug('reporting work as processed: ' . var_export($processed, true)); 231 | $work->send($processed); 232 | 233 | if (true !== $processed) { 234 | /** 235 | * Worker not reliable, quitting 236 | */ 237 | throw new RuntimeException('Worker not reliable, failed to process task: ' . $processed); 238 | } 239 | } 240 | } catch (Throwable $e) { 241 | @error_log('Process worker exception: ' . $e->getMessage()); 242 | } 243 | } 244 | /** 245 | * Keep the references to forks until the end of execution, 246 | * attempt to close the forks nicely, 247 | * zombies will be killed upon worker death anyway 248 | */ 249 | foreach ($forks as $f) { 250 | try { 251 | /** 252 | * isRunning itself closes the process if its ended (not running) 253 | */ 254 | if ($f->isRunning()) { 255 | /** 256 | * stop async process 257 | * @see http://symfony.com/doc/current/components/process.html 258 | */ 259 | $f->checkTimeout(); 260 | usleep(100000); 261 | 262 | $f->clearOutput(); 263 | $f->clearErrorOutput(); 264 | 265 | $f->stop(2, SIGINT); 266 | if ($f->isRunning()) { 267 | $f->signal(SIGKILL); 268 | } 269 | } 270 | } catch (Throwable $e) { 271 | @error_log('Process worker failed to stop forked child: ' . $e->getMessage()); 272 | } 273 | } 274 | $this->finish(); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/Worker/AbstractWorker.php: -------------------------------------------------------------------------------- 1 | adapter = $adapter; 76 | $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL); 77 | $this->setLogger(new ConsoleLogger($output)); 78 | } 79 | 80 | /** 81 | * @param int|null $timeout 82 | */ 83 | public function setWorkTimeout(?int $timeout = null): void 84 | { 85 | $this->workTimeout = $timeout; 86 | } 87 | 88 | /** 89 | * Declare logger 90 | * 91 | * @param LoggerInterface|null $log 92 | */ 93 | public function setLogger(?LoggerInterface $log): void 94 | { 95 | $this->logger = $log; 96 | } 97 | 98 | /** 99 | * Specify worker queue to pick job from 100 | * 101 | */ 102 | public function getQueueName(): string 103 | { 104 | return $this->queueName; 105 | } 106 | 107 | /** 108 | * Set queue this worker is going to use 109 | * 110 | * @param $string 111 | */ 112 | public function setQueueName(string $string): void 113 | { 114 | $this->queueName = (string) $string; 115 | } 116 | 117 | /** 118 | * Quit after processing X amount of pushes 119 | * 120 | * @param int $int 121 | */ 122 | public function setRestartThreshold(int $int): void 123 | { 124 | $this->restartThreshold = (int) $int; 125 | } 126 | 127 | /** 128 | * Quit after reaching idle timeout 129 | * 130 | * @param int $int 131 | */ 132 | public function setIdleTimeout(int $int): void 133 | { 134 | $this->idleTimeout = (int) $int; 135 | } 136 | 137 | /** 138 | * @param bool $triggerError 139 | */ 140 | public function setTriggerErrorOnError(bool $triggerError): void 141 | { 142 | $this->triggerErrorOnError = $triggerError; 143 | } 144 | 145 | /** 146 | * @param string $message 147 | */ 148 | public function logInfo(string $message): void 149 | { 150 | if ($this->logger) { 151 | $this->logger->info($message); 152 | } 153 | } 154 | 155 | /** 156 | * @param string $message 157 | * @deprecated 158 | */ 159 | public function debug(string $message): void 160 | { 161 | $this->logDebug($message); 162 | } 163 | 164 | /** 165 | * @param string $message 166 | */ 167 | public function logDebug(string $message): void 168 | { 169 | if ($this->logger) { 170 | $this->logger->debug($message); 171 | } 172 | } 173 | 174 | /** 175 | * @param string $message 176 | */ 177 | public function logError(string $message): void 178 | { 179 | if ($this->logger) { 180 | $this->logger->error($message); 181 | } 182 | 183 | if ($this->triggerErrorOnError) { 184 | trigger_error($message, E_USER_WARNING); 185 | } 186 | } 187 | 188 | /** 189 | * Initialize provided adapter 190 | * 191 | */ 192 | protected function start(): bool 193 | { 194 | /** 195 | * Tell adapter about our desire for work cycle duration, if any 196 | * Some adapters require it before connecting 197 | */ 198 | $this->adapter->setWorkTimeout($this->workTimeout); 199 | 200 | if (true === $this->adapter->connect()) { 201 | if ($this->adapter->bindRead($this->getQueueName())) { 202 | $this->bind = true; 203 | 204 | /** 205 | * Intercept & DELAY SIGNAL EXECUTION--> 206 | * @see https://wiki.php.net/rfc/async_signals 207 | * @see http://us1.php.net/manual/en/control-structures.declare.php 208 | * @see https://github.com/tpunt/PHP7-Reference/blob/master/php71-reference.md 209 | */ 210 | $this->delaySignalPending = 0; 211 | $me = $this; 212 | if (function_exists('pcntl_signal')) { 213 | $signalHandler = static function ($n) use (&$me): void { 214 | $me->delaySignalPending = $n; 215 | }; 216 | 217 | /** 218 | * Termination request 219 | */ 220 | 221 | pcntl_signal(SIGTERM, $signalHandler); 222 | 223 | /** 224 | * CTRL+C 225 | */ 226 | 227 | pcntl_signal(SIGINT, $signalHandler); 228 | 229 | /** 230 | * shell sends a SIGHUP to all jobs when an interactive login shell exits 231 | */ 232 | 233 | pcntl_signal(SIGHUP, $signalHandler); 234 | 235 | if (function_exists('pcntl_async_signals')) { 236 | /** 237 | * Asynchronously process triggers w/o manual check 238 | */ 239 | pcntl_async_signals(true); 240 | } else { 241 | /** 242 | * Manually process/check delayed triggers 243 | */ 244 | $this->manualDelaySignal = true; 245 | } 246 | } 247 | 248 | return true; 249 | } 250 | } 251 | 252 | return false; 253 | } 254 | 255 | /** 256 | * Process data, 257 | */ 258 | protected function work() 259 | { 260 | if (!$this->bind) { 261 | return; 262 | } 263 | 264 | $timeout = $this->workTimeout; 265 | 266 | /** 267 | * Make sure that, if an timeout and idle timeout were set, the timeout is 268 | * less than the idle timeout 269 | */ 270 | if ($timeout && $this->idleTimeout > 0) { 271 | if ($this->idleTimeout <= $timeout) { 272 | throw new Exception('Time to pick next task cannot be lower than idle timeout'); 273 | } 274 | } 275 | 276 | $jobsdone = 0; 277 | $lastActive = time(); 278 | while (true) { 279 | /** 280 | * Manually process pending signals, updates $requestExit value 281 | * declare(ticks=1) is needed ONLY if we DONT HAVE pcntl_signal_dispatch() call, makes 282 | * EVERY N TICK's check for signal dispatch, 283 | * instead we call pcntl_signal_dispatch() manually where we want to check if there was signal 284 | * @see http://zguide.zeromq.org/php:interrupt 285 | */ 286 | if ((!$this->manualDelaySignal || pcntl_signal_dispatch()) && $this->isTerminationRequested()) { 287 | break; 288 | } 289 | 290 | $this->logDebug('Picking task'); 291 | $job = $this->adapter->pickTask(); 292 | /** 293 | * @todo $job[2] is optinal array of adapter specific results 294 | */ 295 | 296 | if (is_array($job)) { 297 | $lastActive = time(); 298 | 299 | /** 300 | * @see http://php.net/manual/en/generator.send.php 301 | */ 302 | $response = (yield $job[0] => $job[1]); 303 | yield; 304 | 305 | $ack = false; 306 | if (false === $response) { 307 | $this->logDebug('Calling afterWorkFailed, worker reported failure'); 308 | $ack = $this->adapter->afterWorkFailed($job[0]); 309 | } else { 310 | $this->logDebug('Calling afterWorkSuccess, worker reported success'); 311 | $ack = $this->adapter->afterWorkSuccess($job[0]); 312 | } 313 | 314 | if (!$ack) { 315 | throw new Exception('Worker failed to acknowledge job result'); 316 | } 317 | } else { 318 | /** 319 | * Job is a lie 320 | */ 321 | if (!$timeout) { 322 | throw new Exception('Worker failed to fetch new job'); 323 | } 324 | 325 | /** 326 | * Two yield's are not mistake 327 | */ 328 | yield null; 329 | yield null; 330 | } 331 | 332 | /** 333 | * Break infinite loop when a limit condition is reached 334 | */ 335 | if ($this->idleTimeout > 0 && (time() - $lastActive) > $this->idleTimeout - $timeout) { 336 | $this->logDebug('Idle timeout reached, returning job, quitting'); 337 | if ($this->onIdleTimeout()) { 338 | $this->logDebug('onIdleTimeout true'); 339 | 340 | break; 341 | } 342 | 343 | $this->logDebug('onIdleTimeout false'); 344 | } 345 | 346 | if ($this->restartThreshold > 0 && ++$jobsdone > $this->restartThreshold - 1) { 347 | $this->logDebug('Restart threshold reached, returning job, quitting'); 348 | if ($this->onRestartThreshold()) { 349 | $this->logDebug('onRestartThreshold true'); 350 | 351 | break; 352 | } 353 | 354 | $this->logDebug('onRestartThreshold false'); 355 | } 356 | } 357 | } 358 | 359 | /** 360 | */ 361 | protected function onIdleTimeout(): bool 362 | { 363 | return true; 364 | } 365 | 366 | /** 367 | */ 368 | protected function onRestartThreshold(): bool 369 | { 370 | return true; 371 | } 372 | 373 | /** 374 | */ 375 | protected function isTerminationRequested(): bool 376 | { 377 | if ($this->delaySignalPending > 0) { 378 | if (SIGTERM === $this->delaySignalPending || 379 | SIGINT === $this->delaySignalPending || 380 | SIGHUP === $this->delaySignalPending) { 381 | /** 382 | * Received request to stop/terminate process 383 | */ 384 | $this->logDebug('termination requested'); 385 | 386 | return true; 387 | } 388 | } 389 | 390 | return false; 391 | } 392 | 393 | /** 394 | */ 395 | protected function finish(): bool 396 | { 397 | $this->logDebug('finish() called'); 398 | if ($this->bind) { 399 | $this->logDebug('disconnecting binded adapter'); 400 | $this->adapter->disconnect(); 401 | $this->logDebug('disconnected binded adapter'); 402 | 403 | return true; 404 | } 405 | 406 | return false; 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /src/Worker/Amazon/SNS/Application.php: -------------------------------------------------------------------------------- 1 | snsClient = $awsSnsClient; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Worker/Amazon/SNS/Application/PlatformEndpoint.php: -------------------------------------------------------------------------------- 1 | setQueueName($this->getQueueName() . $queueSuffix); 37 | 38 | parent::__construct($adapter); 39 | } 40 | 41 | /** 42 | * Platform that an endpoint will be registered into, can be extracted from 43 | * the queue name 44 | * 45 | */ 46 | public function getPlatform(): string 47 | { 48 | return substr($this->queueName, strrpos($this->queueName, '_') + 1); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Worker/Amazon/SNS/Application/PlatformEndpoint/Publish.php: -------------------------------------------------------------------------------- 1 | logDebug('Started'); 34 | $connected = $this->start(); 35 | 36 | if ($connected) { 37 | try { 38 | $this->logDebug('Connected to queue'); 39 | 40 | $work = $this->work(); 41 | $this->logDebug('After init work generator'); 42 | 43 | /** 44 | * Keep an array with taskId and the number of times it was 45 | * attempted to be reprocessed to avoid starvation 46 | */ 47 | $reprocessedTasks = []; 48 | 49 | /** 50 | * Attempt sending all messages in the queue 51 | */ 52 | foreach ($work as $taskId => $payload) { 53 | $this->logDebug('got some work: ' . ($payload ? 'yes' : 'no')); 54 | 55 | if (!$payload && $this->workTimeout > 0) { 56 | /** 57 | * Just empty loop, no work fetched 58 | */ 59 | continue; 60 | } 61 | 62 | $message = @unserialize($payload); 63 | if (!($message instanceof PublishMessageInterface)) { 64 | $work->send(true); 65 | $this->logDebug('Worker does not support payload of: ' . gettype($message)); 66 | 67 | continue; 68 | } 69 | 70 | try { 71 | $payload = ['Message' => $message->getMessage(), 72 | 'TargetArn' => $message->getTargetArn()]; 73 | 74 | $attributes = $message->getAttributes(); 75 | if ($attributes) { 76 | $payload['MessageAttributes'] = $attributes; 77 | } 78 | 79 | $messageStructure = $message->getMessageStructure(); 80 | if ($messageStructure) { 81 | $payload['MessageStructure'] = $messageStructure; 82 | } 83 | 84 | $this->snsClient->publish($payload); 85 | 86 | $this->logDebug('SNS Client delivered message to endpoint'); 87 | } catch (Throwable $e) { 88 | if (is_subclass_of( 89 | '\BackQ\Worker\Amazon\SNS\Client\Exception\SnsException', 90 | get_class($e) 91 | )) { 92 | 93 | /** 94 | * @see http://docs.aws.amazon.com/sns/latest/api/API_Publish.html#API_Publish_Errors 95 | * @var $e SnsException 96 | */ 97 | $this->logDebug('Could not publish to endpoint with error ' . $e->getAwsErrorCode()); 98 | 99 | /** 100 | * When an endpoint was marked as disabled or the 101 | * request is not valid, the operation can't be performed 102 | * and the endpoint should be removed, send to the specific queue 103 | */ 104 | if ($e->getAwsErrorCode()) { 105 | /** 106 | * Current job to be processed by current queue but 107 | * will send it to a queue to remove endpoints 108 | */ 109 | $this->onFailure($message, $e->getAwsErrorCode()); 110 | } 111 | 112 | /** 113 | * Aws Internal errors and general network error 114 | * will cause the job to be sent back to queue 115 | */ 116 | if (SnsException::INTERNAL === $e->getAwsErrorCode() || 117 | is_subclass_of( 118 | '\BackQ\Worker\Amazon\SNS\Client\Exception\NetworkException', 119 | get_class($e->getPrevious()) 120 | )) { 121 | /** 122 | * Only retry if the max threshold has not been reached 123 | */ 124 | if (isset($reprocessedTasks[$taskId])) { 125 | if ($reprocessedTasks[$taskId] >= self::RETRY_MAX) { 126 | $this->logDebug('Retried re-processing the same job too many times'); 127 | unset($reprocessedTasks[$taskId]); 128 | 129 | /** 130 | * Network error or AWS Internal or other stuff we cant fix, 131 | * pretend it worked 132 | */ 133 | $work->send(true); 134 | 135 | continue; 136 | } 137 | $reprocessedTasks[$taskId] += 1; 138 | } else { 139 | $reprocessedTasks[$taskId] = 1; 140 | } 141 | /** 142 | * Send back to queue for re-try (maybe another process/worker, so 143 | * max retry = NUM_WORKERS*NUM_RETRIES) 144 | */ 145 | $work->send(false); 146 | 147 | continue; 148 | } 149 | } else { 150 | $this->logDebug('Hard error: ' . $e->getMessage()); 151 | trigger_error(self::class . ' ' . $e->getMessage(), E_USER_WARNING); 152 | } 153 | } finally { 154 | $work->send(true); 155 | } 156 | } 157 | } catch (Throwable $e) { 158 | @error_log('[' . date('Y-m-d H:i:s') . '] SNS worker exception: ' . $e->getMessage()); 159 | } 160 | } else { 161 | $this->logDebug('Unable to connect'); 162 | } 163 | 164 | $this->finish(); 165 | } 166 | 167 | /** 168 | * Handles a different flow when Publishing can't be completed 169 | * 170 | * @param \BackQ\Message\Amazon\SNS\Application\PlatformEndpoint\Publish $message 171 | * @param string $getAwsErrorCode 172 | * 173 | * @return null 174 | */ 175 | protected function onFailure( 176 | \BackQ\Message\Amazon\SNS\Application\PlatformEndpoint\Publish $message, 177 | string $getAwsErrorCode 178 | ) { 179 | return null; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/Worker/Amazon/SNS/Application/PlatformEndpoint/Register.php: -------------------------------------------------------------------------------- 1 | logDebug('Started'); 33 | $connected = $this->start(); 34 | if ($connected) { 35 | try { 36 | $this->logDebug('Connected to queue'); 37 | 38 | $work = $this->work(); 39 | $this->logDebug('After init work generator'); 40 | 41 | /** 42 | * Keep an array with taskId and the number of times it was 43 | * attempted to be reprocessed to avoid starvation 44 | */ 45 | $reprocessedTasks = []; 46 | 47 | /** 48 | * Now attempt to register all devices 49 | */ 50 | foreach ($work as $taskId => $payload) { 51 | $this->logDebug('Got some work'); 52 | 53 | if (!$payload && $this->workTimeout > 0) { 54 | /** 55 | * Just empty loop, no work fetched 56 | */ 57 | continue; 58 | } 59 | $message = @unserialize($payload); 60 | $processed = true; 61 | 62 | if (!($message instanceof RegisterMessageInterface)) { 63 | $work->send(true); 64 | $this->logDebug('Worker does not support payload of: ' . gettype($message)); 65 | 66 | continue; 67 | } 68 | 69 | try { 70 | $endpointResult = $this->snsClient->createPlatformEndpoint([ 71 | 'PlatformApplicationArn' => $message->getApplicationArn(), 72 | 'Token' => $message->getToken(), 73 | 'Attributes' => $message->getAttributes(), 74 | ]); 75 | } catch (Throwable $e) { 76 | if (is_subclass_of( 77 | '\BackQ\Worker\Amazon\SNS\Client\Exception\SnsException', 78 | get_class($e) 79 | )) { 80 | /** 81 | * We can't do anything on specific errors and then the job is marked as processed 82 | * @see http://docs.aws.amazon.com/sns/latest/api/API_CreatePlatformEndpoint.html#API_CreatePlatformEndpoint_Errors 83 | * @var $e SnsException 84 | */ 85 | if (in_array( 86 | $e->getAwsErrorCode(), 87 | [SnsException::AUTHERROR, 88 | SnsException::INVALID_PARAM, 89 | SnsException::NOTFOUND] 90 | )) { 91 | $work->send(true); 92 | 93 | continue; 94 | } 95 | 96 | /** 97 | * An internal server error will be considered as a 98 | * temporary issue and we can retry creating the endpoint 99 | * Same process for general network issues 100 | */ 101 | if (SnsException::INTERNAL === $e->getAwsErrorCode() || 102 | is_subclass_of( 103 | '\BackQ\Worker\Amazon\SNS\Client\Exception\NetworkException', 104 | get_class($e->getPrevious()) 105 | )) { 106 | /** 107 | * Only retry if the max threshold has not been reached 108 | */ 109 | if (isset($reprocessedTasks[$taskId])) { 110 | if ($reprocessedTasks[$taskId] >= self::RETRY_MAX) { 111 | $this->logDebug('Retried re-processing the same job too many times'); 112 | unset($reprocessedTasks[$taskId]); 113 | 114 | /** 115 | * Network error or AWS Internal or other stuff we cant fix, 116 | * pretend it worked 117 | */ 118 | $work->send(true); 119 | 120 | continue; 121 | } 122 | $reprocessedTasks[$taskId] += 1; 123 | } else { 124 | $reprocessedTasks[$taskId] = 1; 125 | } 126 | $work->send(false); 127 | 128 | continue; 129 | } 130 | } 131 | } 132 | 133 | /** 134 | * Save the new Application endpoint into database 135 | * If something fails, retry the whole process 136 | */ 137 | if (!empty($endpointResult['EndpointArn'])) { 138 | $result = $this->onSuccess($endpointResult['EndpointArn'], $message); 139 | 140 | if (!$result) { 141 | $work->send(false); 142 | 143 | break; 144 | } 145 | $this->logDebug('Endpoint registered successfully on Service provider and backend'); 146 | } else { 147 | $processed = false; 148 | } 149 | $work->send(true === $processed); 150 | } 151 | } catch (Throwable $e) { 152 | @error_log('[' . date('Y-m-d H:i:s') . '] Register SNS worker exception: ' . $e->getMessage()); 153 | } 154 | } 155 | $this->finish(); 156 | } 157 | 158 | /** 159 | * Handles registering a successfully created Amazon endpoint 160 | * 161 | * @param string $endpointArn 162 | * @param $message 163 | * 164 | */ 165 | protected function onSuccess( 166 | string $endpointArn, 167 | \BackQ\Message\Amazon\SNS\Application\PlatformEndpoint\Register $message 168 | ): bool { 169 | return true; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Worker/Amazon/SNS/Application/PlatformEndpoint/Remove.php: -------------------------------------------------------------------------------- 1 | logDebug('started'); 36 | $connected = $this->start(); 37 | if ($connected) { 38 | try { 39 | $this->logDebug('connected to queue'); 40 | 41 | $work = $this->work(); 42 | $this->logDebug('after init work generator'); 43 | 44 | /** 45 | * Keep an array with taskId and the number of times it was 46 | * attempted to be reprocessed to avoid starvation 47 | */ 48 | $reprocessedTasks = []; 49 | 50 | /** 51 | * Process all messages that were published pointing to a disabled or non existing endpoint 52 | */ 53 | foreach ($work as $taskId => $payload) { 54 | $this->logDebug('got some work'); 55 | 56 | if (!$payload && $this->workTimeout > 0) { 57 | /** 58 | * Just empty loop, no work fetched 59 | */ 60 | continue; 61 | } 62 | 63 | $message = @unserialize($payload); 64 | 65 | if (!($message instanceof RemoveMessageInterface)) { 66 | $work->send(true); 67 | $this->logDebug('Worker does not support payload of: ' . gettype($message)); 68 | 69 | continue; 70 | } 71 | 72 | /** 73 | * Remove the endpoint from Amazon SNS; this won't result in an 74 | * exception if the resource was already deleted 75 | */ 76 | try { 77 | /** 78 | * Endpoint creation is idempotent, then there will always be one Arn per token 79 | * @see http://docs.aws.amazon.com/sns/latest/api/API_DeleteEndpoint.html#API_DeleteEndpoint_Errors 80 | */ 81 | $this->snsClient->deleteEndpoint(['EndpointArn' => $message->getEndpointArn()]); 82 | } catch (Throwable $e) { 83 | if (is_subclass_of( 84 | '\BackQ\Worker\Amazon\SNS\Client\Exception\SnsException', 85 | get_class($e) 86 | )) { 87 | 88 | /** 89 | * @see http://docs.aws.amazon.com/sns/latest/api/API_DeleteEndpoint.html#API_DeleteEndpoint_Errors 90 | * @var $e SnsException 91 | */ 92 | $this->logDebug('Could not delete endpoint with error: ' . $e->getAwsErrorCode()); 93 | 94 | /** 95 | * With issues regarding Authorization or parameters, nothing 96 | * can be done, mark as processed 97 | */ 98 | if (in_array($e->getAwsErrorCode(), [SnsException::AUTHERROR, 99 | SnsException::INVALID_PARAM, 100 | SnsException::NOTFOUND])) { 101 | $work->send(true); 102 | 103 | continue; 104 | } 105 | 106 | /** 107 | * Retry deletion on Internal Server error from Service 108 | * or general network exceptions 109 | */ 110 | if (SnsException::INTERNAL === $e->getAwsErrorCode() || 111 | is_subclass_of( 112 | '\BackQ\Worker\Amazon\SNS\Client\Exception\NetworkException', 113 | get_class($e->getPrevious()) 114 | )) { 115 | /** 116 | * Only retry if the max threshold has not been reached 117 | */ 118 | if (isset($reprocessedTasks[$taskId])) { 119 | if ($reprocessedTasks[$taskId] >= self::RETRY_MAX) { 120 | $this->logDebug('Retried re-processing the same job too many times'); 121 | unset($reprocessedTasks[$taskId]); 122 | 123 | $work->send(true); 124 | 125 | continue; 126 | } 127 | $reprocessedTasks[$taskId] += 1; 128 | } else { 129 | $reprocessedTasks[$taskId] = 1; 130 | } 131 | 132 | $work->send(false); 133 | 134 | continue; 135 | } 136 | } 137 | } 138 | 139 | /** 140 | * Proceed un-registering the device and endpoint (managed by the token provider) 141 | * Retry sending the job to the queue on error/problems deleting 142 | */ 143 | $this->logDebug('Deleting device with Arn ' . $message->getEndpointArn()); 144 | $delSuccess = $this->onSuccess($message); 145 | 146 | if (!$delSuccess) { 147 | /** 148 | * @todo what happens onSuccess if it fails? 149 | */ 150 | $work->send(false); 151 | 152 | continue; 153 | } 154 | 155 | $this->logDebug('Endpoint/Device successfully deleted on Service provider and backend'); 156 | $work->send(true); 157 | } 158 | } catch (Throwable $e) { 159 | @error_log('[' . date('Y-m-d H:i:s') . '] Remove endpoints worker exception: ' . $e->getMessage()); 160 | } 161 | } 162 | $this->finish(); 163 | } 164 | 165 | /** 166 | * Handles actions to be performed on correct deletion of an amazon endpoint 167 | * @param \BackQ\Message\Amazon\SNS\Application\PlatformEndpoint\Remove $message 168 | * 169 | * @phpcs:disable SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter 170 | */ 171 | protected function onSuccess(\BackQ\Message\Amazon\SNS\Application\PlatformEndpoint\Remove $message) 172 | { 173 | return true; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Worker/Amazon/SNS/Client/Exception/NetworkException.php: -------------------------------------------------------------------------------- 1 | 1 push in between the APNS disconnected and we detected the feof() 69 | * 70 | * The longer we wait the less changes we send message to closed socket, but slower the send rates. 71 | * 72 | * Recommended between 50000 and 2000000 73 | * 74 | */ 75 | public int $socketSelectTimeout = 750000; 76 | 77 | public $readWriteTimeout = 10; 78 | 79 | protected $queueName = 'apnsd'; 80 | 81 | private $pushLogger; 82 | 83 | private $pem; 84 | 85 | private $caCert; 86 | 87 | private $environment;//0.05sec 88 | 89 | /** 90 | * Declare Logger 91 | */ 92 | public function setPushLogger(ApnsPHP_Log_Interface $log): void 93 | { 94 | $this->pushLogger = $log; 95 | } 96 | 97 | /** 98 | * Declare CA Authority certificate 99 | */ 100 | public function setRootCertificationAuthority($caCert): void 101 | { 102 | $this->caCert = $caCert; 103 | } 104 | 105 | /** 106 | * Declare working environment 107 | */ 108 | public function setEnvironment($environment = ApnsPHP_Abstract::ENVIRONMENT_SANDBOX): void 109 | { 110 | $this->environment = $environment; 111 | } 112 | 113 | /** 114 | * Declare path to SSL certificate 115 | */ 116 | public function setCertificate($pem): void 117 | { 118 | $this->pem = $pem; 119 | } 120 | 121 | public function run(): void 122 | { 123 | $connected = $this->start(); 124 | $this->logDebug('started'); 125 | $push = null; 126 | if ($connected) { 127 | try { 128 | $this->logDebug('connected to queue'); 129 | $push = new ApnsdPush($this->environment, $this->pem); 130 | if ($this->pushLogger) { 131 | $push->setLogger($this->pushLogger); 132 | } 133 | $push->setRootCertificationAuthority($this->caCert); 134 | 135 | $push->setConnectTimeout($this->connectTimeout); 136 | $push->setReadWriteTimeout($this->readWriteTimeout); 137 | 138 | $this->logDebug('ready to connect to ios'); 139 | 140 | $push->connect(); 141 | 142 | $this->logDebug('ios connected'); 143 | 144 | /** 145 | * Do NOT retry, will restart worker in case things go south 146 | * 1 == no retry 147 | */ 148 | $push->setSendRetryTimes(1); 149 | 150 | $push->setConnectRetryTimes(3); 151 | /** 152 | * Even if documentation states its " timeout value is the maximum time that will elapse" 153 | * @see http://php.net/manual/en/function.stream-select.php 154 | * But in reality it always waits this time before returning (php 5.5.22) 155 | */ 156 | $push->setSocketSelectTimeout($this->socketSelectTimeout); 157 | 158 | $work = $this->work(); 159 | $this->logDebug('after init work generator'); 160 | 161 | #$jobsdone = 0; 162 | #$lastactive = time(); 163 | 164 | /** 165 | * @phpcs:disable SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable 166 | */ 167 | foreach ($work as $_ => $payload) { 168 | $this->logDebug('got some work: ' . ($payload ? 'yes' : 'no')); 169 | 170 | #if ($this->idleTimeout > 0 && (time() - $lastactive) > $this->idleTimeout) { 171 | # $this->logDebug('idle timeout reached, returning job, quitting'); 172 | # $work->send(false); 173 | # $push->disconnect(); 174 | # break; 175 | #} 176 | 177 | if (!$payload && $this->workTimeout > 0) { 178 | /** 179 | * Just empty loop, no work fetched 180 | */ 181 | $work->send(true); 182 | 183 | continue; 184 | } 185 | 186 | #$lastactive = time(); 187 | 188 | #if ($this->restartThreshold > 0 && ++$jobsdone > $this->restartThreshold) { 189 | # $this->logDebug('restart threshold reached, returning job, quitting'); 190 | # $work->send(false); 191 | # $push->disconnect(); 192 | # break; 193 | #} 194 | 195 | $message = @unserialize($payload); 196 | $processed = true; 197 | 198 | if (!($message instanceof ApnsPHP_Message)) { 199 | /** 200 | * Nothing to do + report as a success 201 | */ 202 | $work->send($processed); 203 | $this->logDebug('Worker does not support payload of: ' . gettype($message)); 204 | 205 | continue; 206 | } 207 | 208 | /** 209 | * Empty queue, errors are cleaned automatically in send() 210 | */ 211 | $push->getQueue(true); 212 | 213 | try { 214 | /** 215 | * We send 1 message per push 216 | */ 217 | $push->add($message); 218 | $this->logDebug('job added to apns queue'); 219 | $push->send(); 220 | $this->logDebug('job queue pushed to apple'); 221 | } catch (ApnsPHP_Message_Exception $longpayload) { 222 | $this->logDebug('bad job payload: ' . $longpayload->getMessage()); 223 | } catch (ApnsPHP_Push_Exception $networkIssue) { 224 | $this->logDebug('bad connection network: ' . $networkIssue->getMessage()); 225 | $processed = $networkIssue->getMessage(); 226 | } finally { 227 | /** 228 | * If using Beanstalk and not returned success after TTR time, 229 | * the job considered failed and is put back into pool of "ready" jobs 230 | */ 231 | $work->send((true === $processed)); 232 | } 233 | 234 | if (true === $processed) { 235 | $errors = $push->getErrors(false); 236 | if (!empty($errors)) { 237 | $err = current($errors); 238 | if (empty($err['ERRORS'])) { 239 | throw new RuntimeException('Errors should not be empty here: ' . json_encode($errors)); 240 | } 241 | 242 | $statusCode = $err['ERRORS'][0]['statusCode']; 243 | /** 244 | * Doesnt matter what the code is, APNS closes the connection after it, 245 | * we should reconnect 246 | * 247 | * 0 - none 248 | * 2 - missing token 249 | * 3 - missing topic 250 | * 4 - missing payload 251 | * 5 - invalid token size 252 | * 6 - invalid topic size 253 | * 7 - invalid payld size 254 | * 8 - invalid token 255 | * 10 - shutdown (last message was successfuly sent) 256 | * ... 257 | */ 258 | $this->logDebug('Closing & reconnecting, received code [' . $statusCode . ']'); 259 | $push->disconnect(); 260 | $push->connect(); 261 | } 262 | } else { 263 | /** 264 | * Worker not reliable, quitting 265 | */ 266 | throw new RuntimeException('Worker not reliable, failed to process APNS task: ' . $processed); 267 | } 268 | } 269 | } catch (Throwable $e) { 270 | $this->logDebug('[' . date('Y-m-d H:i:s') . '] EXCEPTION: ' . $e->getMessage()); 271 | } finally { 272 | if ($push) { 273 | $push->disconnect(); 274 | } 275 | } 276 | } 277 | $this->finish(); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/Worker/Closure.php: -------------------------------------------------------------------------------- 1 | start(); 32 | $this->logDebug('started'); 33 | 34 | if ($connected) { 35 | try { 36 | $this->logInfo('before init work generator'); 37 | 38 | $work = $this->work(); 39 | $this->logInfo('after init work generator'); 40 | 41 | /** 42 | * @phpcs:disable SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable 43 | */ 44 | foreach ($work as $_ => $payload) { 45 | $this->logInfo(time() . ' got some work: ' . ($payload ? 'yes' : 'no')); 46 | if (!$payload) { 47 | $work->send(true); 48 | 49 | continue; 50 | } 51 | 52 | $message = @unserialize($payload); 53 | if (!($message instanceof \BackQ\Message\Closure)) { 54 | $work->send(true); 55 | $this->logError('Closure worker does not support payload of: ' . gettype($message)); 56 | 57 | continue; 58 | } 59 | 60 | if (!$message->isReady()) { 61 | /** 62 | * Message should not be processed now 63 | */ 64 | $work->send(false); 65 | 66 | continue; 67 | } 68 | 69 | if ($message->isExpired()) { 70 | $work->send(true); 71 | 72 | continue; 73 | } 74 | 75 | try { 76 | $message->execute(); 77 | } catch (RecoverableException $e) { 78 | /** 79 | * The closure execution failed but can be retried 80 | */ 81 | $this->logError('Failed executing closure ' . $e->getMessage()); 82 | $work->send(false); 83 | 84 | continue; 85 | } catch (Throwable $e) { 86 | $this->logError('Error executing closure ' . $e->getMessage()); 87 | } 88 | $work->send(true); 89 | } 90 | } catch (Throwable $e) { 91 | $this->logError($e->getMessage()); 92 | } 93 | } 94 | $this->finish(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Worker/Closure/RecoverableException.php: -------------------------------------------------------------------------------- 1 | onUninstall = $onUninstall; 61 | $this->onUpdatedTokens = $onUpdatedTokens; 62 | } 63 | 64 | public function setPusher(Zend_Mobile_Push_Gcm $pusher): void 65 | { 66 | $this->pusher = $pusher; 67 | } 68 | 69 | public function run(): void 70 | { 71 | $connected = $this->start(); 72 | $this->logDebug('started'); 73 | if ($connected) { 74 | try { 75 | $this->logDebug('connected to queue'); 76 | 77 | $work = $this->work(); 78 | $this->logDebug('after init work generator'); 79 | 80 | /** 81 | * @phpcs:disable SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable 82 | */ 83 | foreach ($work as $_ => $payload) { 84 | $this->logDebug('got some work: ' . ($payload ? 'yes' : 'no')); 85 | 86 | if (!$payload && $this->workTimeout > 0) { 87 | continue; 88 | } 89 | 90 | $message = @unserialize($payload); 91 | $processed = true; 92 | 93 | if (!($message instanceof Zend_Mobile_Push_Message_Gcm)) { 94 | /** 95 | * Nothing to do + report as a success 96 | */ 97 | $work->send($processed); 98 | $this->logDebug('Worker does not support payload of: ' . gettype($message)); 99 | 100 | continue; 101 | } 102 | try { 103 | 104 | /** 105 | * returns Zend_Http_Response 106 | * @see https://developers.google.com/cloud-messaging/http#message-with-payload--notification-message 107 | * @see https://developers.google.com/cloud-messaging/http-server-ref#interpret-downstream 108 | */ 109 | $zhr = $this->pusher->send($message); 110 | $status = $zhr->getStatus(); 111 | $body = @json_decode($zhr->getBody()); 112 | $this->logDebug('Response body: ' . json_encode($body)); 113 | 114 | $stokens = $message->getToken(); 115 | if ($status >= 500 && $status <= 599) { 116 | 117 | /** 118 | * Errors in the 500-599 range (such as 500 or 503) indicate that there was an internal error 119 | * in the GCM connection server while trying to process the request, 120 | * or that the server is temporarily unavailable 121 | * 122 | * * Unavailable 123 | * * InternalServerError 124 | */ 125 | $processed = false; 126 | } 127 | 128 | switch ($status) { 129 | /** 130 | * JSON request is successful (HTTP status code 200) 131 | * Message was processed successfully. The response body will contain more details 132 | */ 133 | case 200: 134 | if (!$body) { 135 | $processed = false; 136 | } 137 | 138 | if (!$body->canonical_ids && !$body->failure) { 139 | /** 140 | * If the value of failure and canonical_ids is 0, it's not necessary to parse the remainder of the response 141 | */ 142 | } else { 143 | if (is_array($body->results)) { 144 | $brs = $body->results; 145 | for ($i = 0; $i < count($body->results); $i++) { 146 | $br = $brs[$i]; 147 | /** 148 | * @see https://developers.google.com/cloud-messaging/http-server-ref#table5 149 | */ 150 | if (!empty($br->message_id)) { 151 | if ($br->registration_id) { 152 | /** 153 | * replace the original ID with the new value (canonical ID) in your server database 154 | */ 155 | $this->updatedTokens[$stokens[$i]] = $br->registration_id; 156 | } else { 157 | /** 158 | * all OK 159 | */ 160 | } 161 | } else { 162 | if ($br->error) { 163 | switch ($br->error) { 164 | case 'TopicsMessageRateExceeded': 165 | /** 166 | * The rate of messages to subscribers to a particular topic is too high. 167 | * Reduce the number of messages sent for this topic, and do not immediately retry sending. 168 | */ 169 | $processed = $br->error; 170 | 171 | break; 172 | case 'DeviceMessageRateExceeded': 173 | /** 174 | * The rate of messages to a particular device is too high. 175 | * Reduce the number of messages sent to this device and 176 | * do not immediately retry sending to this device. 177 | */ 178 | $processed = $br->error; 179 | 180 | break; 181 | case 'InternalServerError': 182 | /** 183 | * The server encountered an error while trying to process the request. 184 | * You could retry the same request following the requirements listed in "Timeout" 185 | */ 186 | $processed = $br->error; 187 | 188 | break; 189 | case 'Unavailable': 190 | /** 191 | * The server couldn't process the request in time. Retry the same request 192 | * + Honor the Retry-After header if it is included in the response from the GCM 193 | * + Implement exponential back-off in your retry mechanism 194 | */ 195 | break; 196 | case 'InvalidTtl': 197 | /** 198 | * Check that the value used in time_to_live is an integer 199 | * representing a duration in seconds between 0 and 2,419,200 (4 weeks). 200 | */ 201 | break; 202 | case 'InvalidDataKey': 203 | /** 204 | * Check that the payload data does not contain a key 205 | * (such as from, or gcm, or any value prefixed by google) 206 | * that is used internally by GCM. Note that some words 207 | * (such as collapse_key) are also used by GCM but are allowed 208 | * in the payload, in which case the payload value will be 209 | * overridden by the GCM value. 210 | */ 211 | break; 212 | case 'MessageTooBig': 213 | /** 214 | * Check that the total size of the payload data included in a message 215 | * does not exceed GCM limits: 4096 bytes for most messages, 216 | * or 2048 bytes in the case of messages to topics or notification 217 | * messages on iOS. This includes both the keys and the values. 218 | */ 219 | break; 220 | case 'MismatchSenderId': 221 | /** 222 | * A registration token is tied to a certain group of senders. 223 | * When a client app registers for GCM, it must specify which senders 224 | * are allowed to send messages. You should use one of those sender 225 | * IDs when sending messages to the client app. 226 | * If you switch to a different sender, the existing registration tokens won't work. 227 | */ 228 | break; 229 | case 'InvalidPackageName': 230 | /** 231 | * Make sure the message was addressed to a registration token 232 | * whose package name matches the value passed in the request. 233 | */ 234 | break; 235 | case 'InvalidRegistration': 236 | /** 237 | * Check the format of the registration token you pass to the server. 238 | * Make sure it matches the registration token the client app receives 239 | * from registering with GCM. Do not truncate or add additional characters. 240 | */ 241 | $this->uninstalls[] = $stokens[$i]; 242 | 243 | break; 244 | case 'MissingRegistration': 245 | /** 246 | * Check that the request contains a registration token 247 | * (in the registration_id in a plain text message, 248 | * or in the to or registration_ids field in JSON). 249 | */ 250 | break; 251 | case 'NotRegistered': 252 | /** 253 | * should remove the registration ID from your server database 254 | * because the application was uninstalled from the device, 255 | * or the client app isn't configured to receive messages 256 | */ 257 | $this->uninstalls[] = $stokens[$i]; 258 | 259 | break; 260 | default: 261 | /** 262 | * Otherwise, there is something wrong in the registration token passed 263 | * in the request; it is probably a non-recoverable error that will 264 | * also require removing the registration from the server database. 265 | * @see https://developers.google.com/cloud-messaging/http#example-responses 266 | */ 267 | break; 268 | } 269 | } else { 270 | throw new RuntimeException( 271 | 'Received unexpected results body in FCM for message, missing error & message_id: ' . json_encode( 272 | $br 273 | ) 274 | ); 275 | } 276 | } 277 | } 278 | } else { 279 | throw new RuntimeException( 280 | 'Received unexpected results body from FCM: ' . json_encode($body) 281 | ); 282 | } 283 | } 284 | 285 | break; 286 | case 400: 287 | /** 288 | * Only applies for JSON requests. 289 | * Indicates that the request could not be parsed as JSON, 290 | * or it contained invalid fields (for instance, passing a string where a number was expected). 291 | */ 292 | break; 293 | case 401: 294 | /** 295 | * There was an error authenticating the sender account. 296 | * + Authorization header missing or with invalid syntax in HTTP request. 297 | * + Invalid project number sent as key. 298 | * + Key valid but with GCM service disabled. 299 | * + Request originated from a server not whitelisted in the Server Key IPs. 300 | */ 301 | break; 302 | default: 303 | error_log('Unexpected HTTP status code from FCM: ' . $status); 304 | 305 | break; 306 | } 307 | } catch (Throwable $e) { 308 | error_log('Error while sending FCM: ' . $e->getMessage()); 309 | } finally { 310 | $this->postProcessing(); 311 | /** 312 | * If using Beanstalk and not returned success after TTR time, 313 | * the job considered failed and is put back into pool of "ready" jobs 314 | */ 315 | $work->send((true === $processed)); 316 | } 317 | } 318 | } catch (Throwable $e) { 319 | $this->logDebug('[' . date('Y-m-d H:i:s') . '] EXCEPTION: ' . $e->getMessage()); 320 | } 321 | } 322 | $this->finish(); 323 | } 324 | 325 | protected function postProcessing(): void 326 | { 327 | if ($this->onUninstall && is_callable($this->onUninstall)) { 328 | if (($this->onUninstall)($this->uninstalls)) { 329 | $this->uninstalls = []; 330 | } 331 | } 332 | 333 | if ($this->onUpdatedTokens && is_callable($this->onUpdatedTokens)) { 334 | if (($this->onUpdatedTokens)($this->updatedTokens)) { 335 | $this->updatedTokens = []; 336 | } 337 | } 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/Worker/Guzzle.php: -------------------------------------------------------------------------------- 1 | start(); 34 | $this->logDebug('started'); 35 | if ($connected) { 36 | try { 37 | $client = new Client(); 38 | $this->logDebug('connected to queue'); 39 | 40 | $work = $this->work(); 41 | $this->logDebug('after init work generator'); 42 | 43 | /** 44 | * @phpcs:disable SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable 45 | */ 46 | foreach ($work as $_ => $payload) { 47 | $this->logDebug('got some work: ' . ($payload ? 'yes' : 'no')); 48 | 49 | if (!$payload && $this->workTimeout > 0) { 50 | /** 51 | * Just empty loop, no work fetched 52 | */ 53 | $work->send(true); 54 | 55 | continue; 56 | } 57 | 58 | $message = @unserialize($payload); 59 | $processed = true; 60 | 61 | if (!($message instanceof \BackQ\Message\Guzzle)) { 62 | /** 63 | * Nothing to do + report as a success 64 | */ 65 | $work->send($processed); 66 | $this->logDebug('Worker does not support payload of: ' . gettype($message)); 67 | 68 | continue; 69 | } 70 | 71 | if (!$message->isReady()) { 72 | /** 73 | * Message should not be processed now 74 | */ 75 | $work->send(false); 76 | 77 | continue; 78 | } 79 | 80 | if ($message->isExpired()) { 81 | $work->send(true); 82 | 83 | continue; 84 | } 85 | 86 | try { 87 | $me = $this; 88 | 89 | $request = $message->getRequest(); 90 | $promise = $client->sendAsync($request)->then( 91 | static function ($fulfilledResponse) use ($me): void { 92 | /** @var $fulfilledResponse \GuzzleHttp\Psr7\Response */ 93 | $me->logDebug('Request sent, got response ' . $fulfilledResponse->getStatusCode() . 94 | ' ' . json_encode((string) $fulfilledResponse->getBody())); 95 | }, 96 | static function ($rejectedResponse) use ($me): void { 97 | /** @var $rejectedResponse \GuzzleHttp\Exception\RequestException */ 98 | $me->logDebug('Request sent, FAILED with ' . $rejectedResponse->getMessage()); 99 | } 100 | ); 101 | 102 | $promise->wait(); 103 | } catch (Throwable $e) { 104 | error_log('Error while sending FCM: ' . $e->getMessage()); 105 | } finally { 106 | /** 107 | * If using Beanstalk and not returned success after TTR time, 108 | * the job considered failed and is put back into pool of "ready" jobs 109 | */ 110 | $work->send((true === $processed)); 111 | } 112 | } 113 | } catch (Throwable $e) { 114 | $this->logDebug('[' . date('Y-m-d H:i:s') . '] EXCEPTION: ' . $e->getMessage()); 115 | } 116 | } 117 | $this->finish(); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Worker/Serialized.php: -------------------------------------------------------------------------------- 1 | start(); 31 | $this->logInfo('started'); 32 | 33 | if ($connected) { 34 | try { 35 | $this->logInfo('before init work generator'); 36 | 37 | $work = $this->work(); 38 | $this->logInfo('after init work generator'); 39 | 40 | /** 41 | * @phpcs:disable SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable 42 | */ 43 | foreach ($work as $_ => $payload) { 44 | $this->logInfo(time() . ' got some work: ' . ($payload ? 'yes' : 'no')); 45 | 46 | if (!$payload && $this->workTimeout > 0) { 47 | /** 48 | * Just empty loop, no work fetched 49 | */ 50 | $work->send(true); 51 | 52 | continue; 53 | } 54 | 55 | $message = @unserialize($payload); 56 | $processed = true; 57 | 58 | if (!($message instanceof \BackQ\Message\Serialized)) { 59 | $work->send(true); 60 | $this->logError('Worker does not support payload of: ' . gettype($message)); 61 | 62 | continue; 63 | } 64 | 65 | $originalPublisher = $message->getPublisher(); 66 | $originalMessage = $message->getMessage(); 67 | $originalPubOpts = $message->getPublishOptions(); 68 | 69 | if ($originalPublisher && $originalMessage) { 70 | if (!$message->isReady()) { 71 | /** 72 | * Message should not be processed now 73 | */ 74 | $work->send(false); 75 | 76 | continue; 77 | } 78 | 79 | if ($message->isExpired()) { 80 | $work->send(true); 81 | $this->logDebug('Discarding serialized message as already expired'); 82 | 83 | continue; 84 | } 85 | 86 | $processed = false; 87 | try { 88 | if ($this->dispatchOriginalMessage( 89 | $originalPublisher, 90 | $originalMessage, 91 | $originalPubOpts 92 | )) { 93 | $processed = true; 94 | } 95 | } catch (Throwable $ex) { 96 | $this->logError($ex->getMessage()); 97 | } 98 | } else { 99 | if (!$originalMessage) { 100 | $this->logError('Missing original message'); 101 | } 102 | if (!$originalPublisher) { 103 | $this->logError('Missing original publisher'); 104 | } 105 | } 106 | 107 | $work->send($processed); 108 | } 109 | } catch (Throwable $e) { 110 | $this->logError($e->getMessage()); 111 | } 112 | } 113 | $this->finish(); 114 | } 115 | 116 | /** 117 | * @param AbstractPublisher $publisher 118 | * @param AbstractMessage $message 119 | * @param array $publishOptions 120 | */ 121 | private function dispatchOriginalMessage( 122 | AbstractPublisher $publisher, 123 | AbstractMessage $message, 124 | array $publishOptions = [] 125 | ): ?string { 126 | if ($publisher->start()) { 127 | return (string) $publisher->publish($message, $publishOptions); 128 | } 129 | 130 | return null; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Zend/Http/Client/Adapter/Psr7.php: -------------------------------------------------------------------------------- 1 | getHost(); 20 | $host = ('https' === strtolower($uri->getScheme()) ? 'https://' . $host : $host); 21 | 22 | // Build request headers 23 | $path = $uri->getPath(); 24 | if ($uri->getQuery()) { 25 | $path .= '?' . $uri->getQuery(); 26 | } 27 | $request = "{$method} {$host}{$path} HTTP/{$http_ver}\r\n"; 28 | foreach ($headers as $k => $v) { 29 | if (is_string($k)) { 30 | $v = ucfirst($k) . ": $v"; 31 | } 32 | $request .= "$v\r\n"; 33 | } 34 | 35 | // Add the request body 36 | $request .= "\r\n" . $body; 37 | 38 | // Do nothing - just return the request as string 39 | $this->requestRaw = $request; 40 | 41 | return $request; 42 | } 43 | 44 | public function getRequestRaw() 45 | { 46 | /** 47 | * Result can be used by \GuzzleHttp\Psr7\str($result) to create GuzzleHttp Psr7 request 48 | */ 49 | return $this->requestRaw; 50 | } 51 | } 52 | --------------------------------------------------------------------------------