diff --git a/composer.json b/composer.json index 87438a71..6cc51e89 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,12 @@ }, "require-dev": { "phpunit/phpunit": "^8", - "php-coveralls/php-coveralls": "dev-master", + "php-coveralls/php-coveralls": "^2.7", "php-mock/php-mock-phpunit": "^2.6" }, + "suggest": { + "google/cloud-tasks": "Required to use Cloud Tasks push queue routing (^2.3)." + }, "autoload": { "psr-4": { "Google\\AppEngine\\": "src" diff --git a/src/Api/TaskQueue/PushQueue.php b/src/Api/TaskQueue/PushQueue.php index fdfa520f..d7bae85a 100644 --- a/src/Api/TaskQueue/PushQueue.php +++ b/src/Api/TaskQueue/PushQueue.php @@ -50,6 +50,19 @@ final class PushQueue { 'DELETE' => RequestMethod::DELETE ]; + private static $cloudTasksClientFactory = null; + + public static function setCloudTasksClientFactory($factory) { + self::$cloudTasksClientFactory = $factory; + } + + private static function newCloudTasksClient($clientClass) { + if (self::$cloudTasksClientFactory !== null) { + return call_user_func(self::$cloudTasksClientFactory, $clientClass); + } + return new $clientClass(); + } + /** * Construct a PushQueue * @@ -147,17 +160,27 @@ public function addTasks($tasks) { '$tasks must contain at most ' . self::MAX_TASKS_PER_ADD . ' tasks. Actual size: ' . count($tasks)); } - $req = new TaskQueueBulkAddRequest(); - $resp = new TaskQueueBulkAddResponse(); - - $names = []; - $current_time = microtime(true); foreach ($tasks as $task) { if (!($task instanceof PushTask)) { throw new \InvalidArgumentException( 'All values in $tasks must be instances of PushTask. ' . 'Actual type: ' . gettype($task)); } + } + + $useCloudTasks = getenv('GAE_PUSHQUEUE_BACKEND') === 'CLOUD_TASK' || + strtolower((string) getenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE')) === 'true' || + getenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE') === '1'; + if ($useCloudTasks) { + return $this->addTasksCloudTasks($tasks); + } + + $req = new TaskQueueBulkAddRequest(); + $resp = new TaskQueueBulkAddResponse(); + + $names = []; + $current_time = microtime(true); + foreach ($tasks as $task) { $names[] = $task->getName(); $add = $req->addAddRequest(); $add->setQueueName($this->name); @@ -212,4 +235,477 @@ public function addTasks($tasks) { } return $names; } + + private static function getMetadataValue($path) { + $opts = [ + 'http' => [ + 'method' => 'GET', + 'header' => 'Metadata-Flavor: Google', + 'timeout' => 1.0 + ] + ]; + $context = stream_context_create($opts); + $url = 'http://metadata.google.internal/computeMetadata/v1/' . $path; + $result = @file_get_contents($url, false, $context); + return $result; + } + + private static function getRegion() { + $envRegion = getenv('LOCATION_ID') ?: getenv('GAE_LOCATION') ?: getenv('GAE_REGION') ?: getenv('REGION_ID'); + if ($envRegion) { + return $envRegion; + } + static $region = null; + if ($region === null) { + $regionPath = self::getMetadataValue('instance/region'); + if ($regionPath) { + $parts = explode('/', $regionPath); + $region = end($parts); + } + if (!$region) { + $region = getenv('LOCAL_GCP_REGION') ?: 'us-central1'; + } + } + return $region; + } + + private static function getProjectId() { + $envProject = getenv('GOOGLE_CLOUD_PROJECT'); + if ($envProject) { + return $envProject; + } + static $projectId = null; + if ($projectId === null) { + $projectId = self::getMetadataValue('project/project-id'); + if (!$projectId) { + $appId = ApiProxy::getCurrentAppId(); + if (($pos = strpos($appId, '~')) !== false) { + $projectId = substr($appId, $pos + 1); + } else { + $projectId = $appId; + } + } + } + return $projectId; + } + + private function buildCloudTaskObjV2($task, $fullQueueName) { + $headers = []; + $hasContentType = false; + foreach ($task->getHeaders() as $header) { + $pair = explode(':', $header, 2); + $key = trim($pair[0]); + $val = trim($pair[1]); + if (strcasecmp($key, 'Content-Type') === 0) { + $hasContentType = true; + $key = 'Content-Type'; + } elseif (strcasecmp($key, 'X-AppEngine-QueueName') === 0) { + $key = 'X-AppEngine-QueueName'; + } elseif (strcasecmp($key, 'X-AppEngine-TaskName') === 0) { + $key = 'X-AppEngine-TaskName'; + } + $headers[$key] = $val; + } + if (!$hasContentType) { + $headers['Content-Type'] = 'application/octet-stream'; + } + if (!isset($headers['X-AppEngine-QueueName'])) { + $headers['X-AppEngine-QueueName'] = $this->name; + } + + $taskName = $task->getName(); + if ($taskName && !isset($headers['X-AppEngine-TaskName'])) { + $headers['X-AppEngine-TaskName'] = $taskName; + } + + $methodMap = [ + 'POST' => 1, + 'GET' => 2, + 'HEAD' => 3, + 'PUT' => 4, + 'DELETE' => 5, + 'PATCH' => 6, + 'OPTIONS' => 7, + ]; + $httpMethod = isset($methodMap[$task->getMethod()]) ? $methodMap[$task->getMethod()] : 1; + + $appEngineReq = new \Google\Cloud\Tasks\V2\AppEngineHttpRequest(); + $appEngineReq->setRelativeUri($task->getUrl() ?: '/'); + $appEngineReq->setHttpMethod($httpMethod); + + foreach ($headers as $k => $v) { + $appEngineReq->getHeaders()[$k] = $v; + } + + if ($task->getMethod() === 'POST' || $task->getMethod() === 'PUT') { + if ($task->getQueryData()) { + $body = http_build_query($task->getQueryData()); + if (strlen($body) > PushTask::MAX_TASK_SIZE_BYTES) { + throw new TaskQueueException('Task greater than maximum size of ' . + PushTask::MAX_TASK_SIZE_BYTES . '. size: ' . strlen($body)); + } + $appEngineReq->setBody($body); + } + } + + $taskObj = new \Google\Cloud\Tasks\V2\Task(); + if ($taskName) { + $fullTaskName = $fullQueueName . "/tasks/" . $taskName; + $taskObj->setName($fullTaskName); + } + $taskObj->setAppEngineHttpRequest($appEngineReq); + + if ($task->getDelaySeconds() > 0) { + $taskObj->setScheduleTime(self::buildScheduleTime($task->getDelaySeconds())); + } + + return $taskObj; + } + + /** + * Builds a Timestamp for now + $delaySeconds, preserving fractional seconds. + */ + private static function buildScheduleTime($delaySeconds) { + $scheduleTime = microtime(true) + $delaySeconds; + $seconds = (int) floor($scheduleTime); + $nanos = (int) round(($scheduleTime - $seconds) * 1e9); + if ($nanos >= 1000000000) { + $seconds += 1; + $nanos -= 1000000000; + } + $ts = new \Google\Protobuf\Timestamp(); + $ts->setSeconds($seconds); + $ts->setNanos($nanos); + return $ts; + } + + private function buildCloudTaskObjV2beta3($task, $fullQueueName) { + $headers = []; + $hasContentType = false; + foreach ($task->getHeaders() as $header) { + $pair = explode(':', $header, 2); + $key = trim($pair[0]); + $val = trim($pair[1]); + if (strcasecmp($key, 'Content-Type') === 0) { + $hasContentType = true; + $key = 'Content-Type'; + } elseif (strcasecmp($key, 'X-AppEngine-QueueName') === 0) { + $key = 'X-AppEngine-QueueName'; + } elseif (strcasecmp($key, 'X-AppEngine-TaskName') === 0) { + $key = 'X-AppEngine-TaskName'; + } + $headers[$key] = $val; + } + if (!$hasContentType) { + $headers['Content-Type'] = 'application/octet-stream'; + } + if (!isset($headers['X-AppEngine-QueueName'])) { + $headers['X-AppEngine-QueueName'] = $this->name; + } + + $taskName = $task->getName(); + if ($taskName && !isset($headers['X-AppEngine-TaskName'])) { + $headers['X-AppEngine-TaskName'] = $taskName; + } + + $methodMap = [ + 'POST' => 1, + 'GET' => 2, + 'HEAD' => 3, + 'PUT' => 4, + 'DELETE' => 5, + 'PATCH' => 6, + 'OPTIONS' => 7, + ]; + $httpMethod = isset($methodMap[$task->getMethod()]) ? $methodMap[$task->getMethod()] : 1; + + $appEngineReq = new \Google\Cloud\Tasks\V2beta3\AppEngineHttpRequest(); + $appEngineReq->setRelativeUri($task->getUrl() ?: '/'); + $appEngineReq->setHttpMethod($httpMethod); + + foreach ($headers as $k => $v) { + $appEngineReq->getHeaders()[$k] = $v; + } + + if ($task->getMethod() === 'POST' || $task->getMethod() === 'PUT') { + if ($task->getQueryData()) { + $body = http_build_query($task->getQueryData()); + if (strlen($body) > PushTask::MAX_TASK_SIZE_BYTES) { + throw new TaskQueueException('Task greater than maximum size of ' . + PushTask::MAX_TASK_SIZE_BYTES . '. size: ' . strlen($body)); + } + $appEngineReq->setBody($body); + } + } + + $taskObj = new \Google\Cloud\Tasks\V2beta3\Task(); + if ($taskName) { + $fullTaskName = $fullQueueName . "/tasks/" . $taskName; + $taskObj->setName($fullTaskName); + } + $taskObj->setAppEngineHttpRequest($appEngineReq); + + if ($task->getDelaySeconds() > 0) { + $taskObj->setScheduleTime(self::buildScheduleTime($task->getDelaySeconds())); + } + + return $taskObj; + } + + /** + * Returns [clientClass, apiVersion, usesRequestObjects] for the newest Cloud + * Tasks client installed, or null if no Cloud Tasks client is available. + */ + private static function singleTaskClientSpec() { + $candidates = [ + ['\Google\Cloud\Tasks\V2\Client\CloudTasksClient', 'V2', true], + ['\Google\Cloud\Tasks\V2\CloudTasksClient', 'V2', false], + ['\Google\Cloud\Tasks\V2beta3\Client\CloudTasksClient', 'V2beta3', true], + ['\Google\Cloud\Tasks\V2beta3\CloudTasksClient', 'V2beta3', false], + ]; + foreach ($candidates as $candidate) { + if (class_exists($candidate[0])) { + return $candidate; + } + } + return null; + } + + /** + * Creates a single task. If $client is null, a client is created and closed + * within this call; otherwise the caller owns the client's lifecycle. + */ + private function createSingleTaskCloudTasks($task, $fullQueueName, $client = null) { + $spec = self::singleTaskClientSpec(); + if ($spec === null) { + throw new TaskQueueException('Cloud Tasks Client SDK is not available.'); + } + list($clientClass, $apiVersion, $usesRequestObjects) = $spec; + + if ($apiVersion === 'V2') { + $taskObj = $this->buildCloudTaskObjV2($task, $fullQueueName); + $requestClass = '\Google\Cloud\Tasks\V2\CreateTaskRequest'; + } else { + $taskObj = $this->buildCloudTaskObjV2beta3($task, $fullQueueName); + $requestClass = '\Google\Cloud\Tasks\V2beta3\CreateTaskRequest'; + } + + $ownsClient = ($client === null); + if ($ownsClient) { + $client = self::newCloudTasksClient($clientClass); + } + try { + if ($usesRequestObjects) { + $createTaskReq = (new $requestClass()) + ->setParent($fullQueueName) + ->setTask($taskObj); + $response = $client->createTask($createTaskReq); + } else { + $response = $client->createTask($fullQueueName, $taskObj); + } + $parts = explode('/', $response->getName()); + return [end($parts)]; + } catch (\Google\ApiCore\ApiException $e) { + if ($e->getStatus() === 'ALREADY_EXISTS' || $e->getCode() === 409 || self::isAlreadyExistsError($e->getCode(), $e->getMessage())) { + throw new TaskAlreadyExistsException('Task exists already: ' . $e->getMessage()); + } + throw new TaskQueueException('Cloud Tasks Client SDK createTask failed: ' . $e->getMessage()); + } finally { + if ($ownsClient) { + $client->close(); + } + } + } + + private static function processBatchCreateResponse($response, $chunk, &$names) { + if (method_exists($response, 'pollUntilComplete') && !$response->isDone()) { + $response->pollUntilComplete(); + } + + $metadata = method_exists($response, 'getMetadata') ? $response->getMetadata() : null; + $failedRequests = ($metadata && method_exists($metadata, 'getFailedRequests')) ? $metadata->getFailedRequests() : null; + $exception = null; + if ($failedRequests) { + foreach ($chunk as $idx => $task) { + $hasFailure = (is_array($failedRequests) && isset($failedRequests[$idx])) || + (is_object($failedRequests) && method_exists($failedRequests, 'offsetExists') && $failedRequests->offsetExists($idx)); + if ($hasFailure) { + $errStatus = is_array($failedRequests) ? $failedRequests[$idx] : $failedRequests->offsetGet($idx); + $code = ($errStatus && method_exists($errStatus, 'getCode')) ? $errStatus->getCode() : 0; + if ($code !== 0) { + $msg = ($errStatus && method_exists($errStatus, 'getMessage')) ? $errStatus->getMessage() : ''; + if (self::isAlreadyExistsError($code, $msg)) { + $exception = new TaskAlreadyExistsException('Task exists already: ' . $msg); + } elseif (stripos($msg, 'queue does not exist') !== false || stripos($msg, 'queue no longer exists') !== false) { + throw new TaskQueueException('Unknown queue: ' . $msg); + } else { + throw new TaskQueueException('Task creation failed: ' . $msg); + } + } + } + } + } + if ($exception !== null) { + throw $exception; + } + + if (method_exists($response, 'operationFailed') && $response->operationFailed()) { + $errStatus = method_exists($response, 'getError') ? $response->getError() : null; + $code = ($errStatus && method_exists($errStatus, 'getCode')) ? $errStatus->getCode() : 0; + $msg = ($errStatus && method_exists($errStatus, 'getMessage')) ? $errStatus->getMessage() : 'Batch operation failed'; + if (self::isAlreadyExistsError($code, $msg)) { + throw new TaskAlreadyExistsException('Task exists already: ' . $msg); + } + throw new TaskQueueException('Task creation failed: ' . $msg); + } + + $resObj = method_exists($response, 'getResult') ? $response->getResult() : + (method_exists($response, 'getResponse') ? $response->getResponse() : $response); + if ($resObj && method_exists($resObj, 'getTasks')) { + foreach ($resObj->getTasks() as $resTask) { + $parts = explode('/', $resTask->getName()); + $names[] = end($parts); + } + } + } + + private function addTasksCloudTasks($tasks) { + $projectId = self::getProjectId(); + $region = self::getRegion(); + $fullQueueName = "projects/" . $projectId . "/locations/" . $region . "/queues/" . $this->name; + + if (count($tasks) === 1) { + return $this->createSingleTaskCloudTasks($tasks[0], $fullQueueName); + } + + $names = []; + $chunks = array_chunk($tasks, 100); + + // 1. Try V2 batchCreateTasks if available + $v2ClientClass = null; + if (class_exists('\Google\Cloud\Tasks\V2\Client\CloudTasksClient') && method_exists('\Google\Cloud\Tasks\V2\Client\CloudTasksClient', 'batchCreateTasks')) { + $v2ClientClass = '\Google\Cloud\Tasks\V2\Client\CloudTasksClient'; + } elseif (class_exists('\Google\Cloud\Tasks\V2\CloudTasksClient') && method_exists('\Google\Cloud\Tasks\V2\CloudTasksClient', 'batchCreateTasks')) { + $v2ClientClass = '\Google\Cloud\Tasks\V2\CloudTasksClient'; + } + + if ($v2ClientClass !== null) { + // Create the client once and reuse it for every chunk. + $client = self::newCloudTasksClient($v2ClientClass); + try { + foreach ($chunks as $chunk) { + $createTaskRequests = []; + foreach ($chunk as $task) { + $taskObj = $this->buildCloudTaskObjV2($task, $fullQueueName); + $createTaskReq = (new \Google\Cloud\Tasks\V2\CreateTaskRequest()) + ->setParent($fullQueueName) + ->setTask($taskObj); + $createTaskRequests[] = $createTaskReq; + } + + try { + if (class_exists('\Google\Cloud\Tasks\V2\BatchCreateTasksRequest')) { + $batchReq = (new \Google\Cloud\Tasks\V2\BatchCreateTasksRequest()) + ->setParent($fullQueueName) + ->setRequests($createTaskRequests); + try { + $response = $client->batchCreateTasks($batchReq); + } catch (\TypeError $te) { + $response = $client->batchCreateTasks($fullQueueName, $createTaskRequests); + } + } else { + $response = $client->batchCreateTasks($fullQueueName, $createTaskRequests); + } + + self::processBatchCreateResponse($response, $chunk, $names); + } catch (\Google\ApiCore\ApiException $e) { + if ($e->getStatus() === 'ALREADY_EXISTS' || $e->getCode() === 409 || self::isAlreadyExistsError($e->getCode(), $e->getMessage())) { + throw new TaskAlreadyExistsException('Task exists already: ' . $e->getMessage()); + } + throw new TaskQueueException('Cloud Tasks Client SDK batchCreate failed: ' . $e->getMessage()); + } + } + } finally { + $client->close(); + } + return $names; + } + + // 2. Try V2beta3 batchCreateTasks + $betaClientClass = null; + if (class_exists('\Google\Cloud\Tasks\V2beta3\Client\CloudTasksClient') && method_exists('\Google\Cloud\Tasks\V2beta3\Client\CloudTasksClient', 'batchCreateTasks')) { + $betaClientClass = '\Google\Cloud\Tasks\V2beta3\Client\CloudTasksClient'; + } elseif (class_exists('\Google\Cloud\Tasks\V2beta3\CloudTasksClient') && method_exists('\Google\Cloud\Tasks\V2beta3\CloudTasksClient', 'batchCreateTasks')) { + $betaClientClass = '\Google\Cloud\Tasks\V2beta3\CloudTasksClient'; + } + + if ($betaClientClass !== null) { + // Create the client once and reuse it for every chunk. + $client = self::newCloudTasksClient($betaClientClass); + try { + foreach ($chunks as $chunk) { + $createTaskRequests = []; + foreach ($chunk as $task) { + $taskObj = $this->buildCloudTaskObjV2beta3($task, $fullQueueName); + $createTaskReq = new \Google\Cloud\Tasks\V2beta3\CreateTaskRequest(); + $createTaskReq->setParent($fullQueueName); + $createTaskReq->setTask($taskObj); + $createTaskRequests[] = $createTaskReq; + } + + try { + if (class_exists('\Google\Cloud\Tasks\V2beta3\BatchCreateTasksRequest')) { + $batchReq = (new \Google\Cloud\Tasks\V2beta3\BatchCreateTasksRequest()) + ->setParent($fullQueueName) + ->setRequests($createTaskRequests); + try { + $response = $client->batchCreateTasks($batchReq); + } catch (\TypeError $te) { + $response = $client->batchCreateTasks($fullQueueName, $createTaskRequests); + } + } else { + $response = $client->batchCreateTasks($fullQueueName, $createTaskRequests); + } + + self::processBatchCreateResponse($response, $chunk, $names); + } catch (\Google\ApiCore\ApiException $e) { + if ($e->getStatus() === 'ALREADY_EXISTS' || $e->getCode() === 409 || self::isAlreadyExistsError($e->getCode(), $e->getMessage())) { + throw new TaskAlreadyExistsException('Task exists already: ' . $e->getMessage()); + } + throw new TaskQueueException('Cloud Tasks Client SDK batchCreate failed: ' . $e->getMessage()); + } + } + } finally { + $client->close(); + } + return $names; + } + + // Fallback: If native batchCreateTasks is not available in the installed Cloud Tasks SDK, + // enqueue tasks individually using createTask over a single shared client. + $spec = self::singleTaskClientSpec(); + if ($spec === null) { + throw new TaskQueueException('Cloud Tasks Client SDK is not available.'); + } + $client = self::newCloudTasksClient($spec[0]); + try { + foreach ($tasks as $task) { + $res = $this->createSingleTaskCloudTasks($task, $fullQueueName, $client); + $names[] = $res[0]; + } + } finally { + $client->close(); + } + return $names; + } + + private static function isAlreadyExistsError($errCode, $errMsg) { + // Cloud Tasks reports duplicate and tombstoned task names as ALREADY_EXISTS. + // NOT_FOUND is intentionally not mapped here so that missing queues, + // projects or locations surface as TaskQueueException. + return $errCode === 6 || $errCode === 409 || + stripos($errMsg, 'already exists') !== false || + stripos($errMsg, 'existed too recently') !== false || + stripos($errMsg, 'tombstoned') !== false; + } } diff --git a/tests/Api/TaskQueue/PushQueueTest.php b/tests/Api/TaskQueue/PushQueueTest.php index 2901d6ab..2ade7d0d 100644 --- a/tests/Api/TaskQueue/PushQueueTest.php +++ b/tests/Api/TaskQueue/PushQueueTest.php @@ -278,4 +278,437 @@ public function testPushQueueTooManyTasksError() { $this->apiProxyMock->verify(); } + private static function ensureCloudTasksV2StubsLoaded() { + if (!class_exists('\Google\Protobuf\Timestamp')) { + eval(' + namespace Google\Protobuf; + class Timestamp { + private $seconds = 0; + private $nanos = 0; + public function setSeconds($s) { $this->seconds = $s; return $this; } + public function getSeconds() { return $this->seconds; } + public function setNanos($n) { $this->nanos = $n; return $this; } + public function getNanos() { return $this->nanos; } + } + '); + } + if (!class_exists('\Google\Rpc\Status')) { + eval(' + namespace Google\Rpc; + class Status { + private $code = 0; + private $message = ""; + public function setCode($c) { $this->code = $c; return $this; } + public function getCode() { return $this->code; } + public function setMessage($m) { $this->message = $m; return $this; } + public function getMessage() { return $this->message; } + } + '); + } + if (!class_exists('\Google\ApiCore\ApiException')) { + eval(' + namespace Google\ApiCore; + class ApiException extends \Exception { + private $status; + public function __construct($message, $code, $status = "") { + parent::__construct($message, $code); + $this->status = $status; + } + public function getStatus() { return $this->status; } + } + '); + } + if (!class_exists('\Google\Cloud\Tasks\V2\Task')) { + eval(' + namespace Google\Cloud\Tasks\V2; + class AppEngineHttpRequest { + private $relativeUri = "/"; + private $httpMethod = 1; + private $headers; + private $body = ""; + public function __construct() { $this->headers = new \ArrayObject(); } + public function setRelativeUri($u) { $this->relativeUri = $u; return $this; } + public function getRelativeUri() { return $this->relativeUri; } + public function setHttpMethod($m) { $this->httpMethod = $m; return $this; } + public function getHttpMethod() { return $this->httpMethod; } + public function getHeaders() { return $this->headers; } + public function setBody($b) { $this->body = $b; return $this; } + public function getBody() { return $this->body; } + } + class Task { + private $name = ""; + private $appEngineHttpRequest = null; + private $scheduleTime = null; + public function setName($n) { $this->name = $n; return $this; } + public function getName() { return $this->name; } + public function setAppEngineHttpRequest($r) { $this->appEngineHttpRequest = $r; return $this; } + public function getAppEngineHttpRequest() { return $this->appEngineHttpRequest; } + public function setScheduleTime($t) { $this->scheduleTime = $t; return $this; } + public function getScheduleTime() { return $this->scheduleTime; } + } + class CreateTaskRequest { + private $parent = ""; + private $task = null; + public function setParent($p) { $this->parent = $p; return $this; } + public function getParent() { return $this->parent; } + public function setTask($t) { $this->task = $t; return $this; } + public function getTask() { return $this->task; } + } + class BatchCreateTasksRequest { + private $parent = ""; + private $requests = []; + public function setParent($p) { $this->parent = $p; return $this; } + public function getParent() { return $this->parent; } + public function setRequests($r) { $this->requests = $r; return $this; } + public function getRequests() { return $this->requests; } + } + class BatchCreateTasksResponse { + private $tasks = []; + public function setTasks($t) { $this->tasks = $t; return $this; } + public function getTasks() { return $this->tasks; } + } + class BatchCreateTasksMetadata { + private $failedRequests = []; + public function setFailedRequests($f) { $this->failedRequests = $f; return $this; } + public function getFailedRequests() { return $this->failedRequests; } + } + '); + } + if (!class_exists('\Google\Cloud\Tasks\V2\Client\CloudTasksClient')) { + eval(' + namespace Google\Cloud\Tasks\V2\Client; + class CloudTasksClient { + public function createTask($req) {} + public function batchCreateTasks($req) {} + public function close() {} + } + '); + } + } + + public function testCloudTasksV2SingleCreateTask() { + self::ensureCloudTasksV2StubsLoaded(); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE=true'); + putenv('GOOGLE_CLOUD_PROJECT=test-proj'); + putenv('LOCATION_ID=us-central1'); + try { + $capturedReq = null; + PushQueue::setCloudTasksClientFactory(function ($className) use (&$capturedReq) { + return new class($capturedReq) { + private $capturedReqRef; + public function __construct(&$ref) { $this->capturedReqRef = &$ref; } + public function createTask($req) { + $this->capturedReqRef = $req; + return (new \Google\Cloud\Tasks\V2\Task()) + ->setName('projects/test-proj/locations/us-central1/queues/default/tasks/single-1'); + } + public function close() {} + }; + }); + + $task = new PushTask('/worker/push', ['foo' => 'bar'], [ + 'name' => 'single-1', + 'delay_seconds' => 30, + ]); + $queue = new PushQueue('default'); + $names = $queue->addTasks([$task]); + $this->assertEquals(['single-1'], $names); + $this->assertNotNull($capturedReq); + $this->assertEquals( + 'projects/test-proj/locations/us-central1/queues/default', + $capturedReq->getParent()); + $ctTask = $capturedReq->getTask(); + $this->assertEquals( + 'projects/test-proj/locations/us-central1/queues/default/tasks/single-1', + $ctTask->getName()); + $this->assertEquals('/worker/push', $ctTask->getAppEngineHttpRequest()->getRelativeUri()); + $this->assertEquals('foo=bar', $ctTask->getAppEngineHttpRequest()->getBody()); + $this->assertNotNull($ctTask->getScheduleTime()); + } finally { + PushQueue::setCloudTasksClientFactory(null); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE'); + putenv('GOOGLE_CLOUD_PROJECT'); + putenv('LOCATION_ID'); + } + } + + public function testCloudTasksV2SingleCreateTaskAlreadyExists() { + self::ensureCloudTasksV2StubsLoaded(); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE=true'); + putenv('GOOGLE_CLOUD_PROJECT=test-proj'); + putenv('LOCATION_ID=us-central1'); + try { + PushQueue::setCloudTasksClientFactory(function ($className) { + return new class { + public function createTask($req) { + throw new \Google\ApiCore\ApiException('Task already exists', 409, 'ALREADY_EXISTS'); + } + public function close() {} + }; + }); + + $this->expectException('\Google\AppEngine\Api\TaskQueue\TaskAlreadyExistsException'); + $task = new PushTask('/worker/push', [], ['name' => 'dup-single']); + $queue = new PushQueue('default'); + $queue->addTasks([$task]); + } finally { + PushQueue::setCloudTasksClientFactory(null); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE'); + putenv('GOOGLE_CLOUD_PROJECT'); + putenv('LOCATION_ID'); + } + } + + public function testCloudTasksV2BatchCreateTasksViaGetResult() { + self::ensureCloudTasksV2StubsLoaded(); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE=true'); + putenv('GOOGLE_CLOUD_PROJECT=test-proj'); + putenv('LOCATION_ID=us-central1'); + try { + PushQueue::setCloudTasksClientFactory(function ($className) { + return new class { + public function batchCreateTasks($req) { + return new class { + public function isDone() { return true; } + public function pollUntilComplete() { return true; } + public function operationFailed() { return false; } + public function getMetadata() { + return new \Google\Cloud\Tasks\V2\BatchCreateTasksMetadata(); + } + public function getResult() { + $t1 = (new \Google\Cloud\Tasks\V2\Task()) + ->setName('projects/test-proj/locations/us-central1/queues/default/tasks/task-1'); + $t2 = (new \Google\Cloud\Tasks\V2\Task()) + ->setName('projects/test-proj/locations/us-central1/queues/default/tasks/task-2'); + return (new \Google\Cloud\Tasks\V2\BatchCreateTasksResponse()) + ->setTasks([$t1, $t2]); + } + }; + } + public function close() {} + }; + }); + + $task1 = new PushTask('/url1', ['k' => 'v1'], ['name' => 'task-1']); + $task2 = new PushTask('/url2', ['k' => 'v2'], ['name' => 'task-2']); + $queue = new PushQueue('default'); + $names = $queue->addTasks([$task1, $task2]); + $this->assertEquals(['task-1', 'task-2'], $names); + } finally { + PushQueue::setCloudTasksClientFactory(null); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE'); + putenv('GOOGLE_CLOUD_PROJECT'); + putenv('LOCATION_ID'); + } + } + + public function testCloudTasksV2BatchCreateTasksFailedRequestsAlreadyExists() { + self::ensureCloudTasksV2StubsLoaded(); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE=true'); + putenv('GOOGLE_CLOUD_PROJECT=test-proj'); + putenv('LOCATION_ID=us-central1'); + try { + PushQueue::setCloudTasksClientFactory(function ($className) { + return new class { + public function batchCreateTasks($req) { + return new class { + public function isDone() { return true; } + public function pollUntilComplete() { return true; } + public function operationFailed() { return false; } + public function getMetadata() { + $st = (new \Google\Rpc\Status()) + ->setCode(6) + ->setMessage('The task cannot be created because a task with this name existed too recently'); + return (new \Google\Cloud\Tasks\V2\BatchCreateTasksMetadata()) + ->setFailedRequests([1 => $st]); + } + public function getResult() { + $t1 = (new \Google\Cloud\Tasks\V2\Task()) + ->setName('projects/test-proj/locations/us-central1/queues/default/tasks/task-1'); + return (new \Google\Cloud\Tasks\V2\BatchCreateTasksResponse()) + ->setTasks([$t1]); + } + }; + } + public function close() {} + }; + }); + + $this->expectException('\Google\AppEngine\Api\TaskQueue\TaskAlreadyExistsException'); + $task1 = new PushTask('/url1'); + $task2 = new PushTask('/url2', [], ['name' => 'dup-task']); + $queue = new PushQueue('default'); + $queue->addTasks([$task1, $task2]); + } finally { + PushQueue::setCloudTasksClientFactory(null); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE'); + putenv('GOOGLE_CLOUD_PROJECT'); + putenv('LOCATION_ID'); + } + } + + public function testCloudTasksV2BatchCreateTasksFailedRequestsUnknownQueue() { + self::ensureCloudTasksV2StubsLoaded(); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE=true'); + putenv('GOOGLE_CLOUD_PROJECT=test-proj'); + putenv('LOCATION_ID=us-central1'); + try { + PushQueue::setCloudTasksClientFactory(function ($className) { + return new class { + public function batchCreateTasks($req) { + return new class { + public function isDone() { return true; } + public function pollUntilComplete() { return true; } + public function operationFailed() { return false; } + public function getMetadata() { + $st = (new \Google\Rpc\Status()) + ->setCode(5) + ->setMessage('Queue does not exist'); + return (new \Google\Cloud\Tasks\V2\BatchCreateTasksMetadata()) + ->setFailedRequests([0 => $st]); + } + public function getResult() { + return new \Google\Cloud\Tasks\V2\BatchCreateTasksResponse(); + } + }; + } + public function close() {} + }; + }); + + $this->expectException('\Google\AppEngine\Api\TaskQueue\TaskQueueException'); + $this->expectExceptionMessage('Unknown queue'); + $task1 = new PushTask('/url1'); + $task2 = new PushTask('/url2'); + $queue = new PushQueue('missing-queue'); + $queue->addTasks([$task1, $task2]); + } finally { + PushQueue::setCloudTasksClientFactory(null); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE'); + putenv('GOOGLE_CLOUD_PROJECT'); + putenv('LOCATION_ID'); + } + } + + public function testCloudTasksV2SingleCreateTaskFractionalDelay() { + self::ensureCloudTasksV2StubsLoaded(); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE=true'); + putenv('GOOGLE_CLOUD_PROJECT=test-proj'); + putenv('LOCATION_ID=us-central1'); + try { + $capturedReq = null; + PushQueue::setCloudTasksClientFactory(function ($className) use (&$capturedReq) { + return new class($capturedReq) { + private $capturedReqRef; + public function __construct(&$ref) { $this->capturedReqRef = &$ref; } + public function createTask($req) { + $this->capturedReqRef = $req; + return (new \Google\Cloud\Tasks\V2\Task()) + ->setName('projects/test-proj/locations/us-central1/queues/default/tasks/frac-1'); + } + public function close() {} + }; + }); + + MockMicrotime::expect(12345.25); + $task = new PushTask('/worker/push', [], [ + 'name' => 'frac-1', + 'delay_seconds' => 0.5, + ]); + $queue = new PushQueue('default'); + $queue->addTasks([$task]); + + $scheduleTime = $capturedReq->getTask()->getScheduleTime(); + $this->assertEquals(12345, $scheduleTime->getSeconds()); + $this->assertEquals(750000000, $scheduleTime->getNanos()); + } finally { + PushQueue::setCloudTasksClientFactory(null); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE'); + putenv('GOOGLE_CLOUD_PROJECT'); + putenv('LOCATION_ID'); + } + } + + public function testCloudTasksV2SingleCreateTaskNotFoundIsNotAlreadyExists() { + self::ensureCloudTasksV2StubsLoaded(); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE=true'); + putenv('GOOGLE_CLOUD_PROJECT=test-proj'); + putenv('LOCATION_ID=us-central1'); + try { + PushQueue::setCloudTasksClientFactory(function ($className) { + return new class { + public function createTask($req) { + throw new \Google\ApiCore\ApiException('Requested entity was not found.', 5, 'NOT_FOUND'); + } + public function close() {} + }; + }); + + $task = new PushTask('/worker/push', [], ['name' => 'nf-1']); + $queue = new PushQueue('default'); + try { + $queue->addTasks([$task]); + $this->fail('Expected TaskQueueException'); + } catch (TaskQueueException $e) { + $this->assertNotInstanceOf(TaskAlreadyExistsException::class, $e); + $this->assertStringContainsString('Requested entity was not found', $e->getMessage()); + } + } finally { + PushQueue::setCloudTasksClientFactory(null); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE'); + putenv('GOOGLE_CLOUD_PROJECT'); + putenv('LOCATION_ID'); + } + } + + public function testCloudTasksV2BatchCreateTasksUsesSingleClient() { + self::ensureCloudTasksV2StubsLoaded(); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE=true'); + putenv('GOOGLE_CLOUD_PROJECT=test-proj'); + putenv('LOCATION_ID=us-central1'); + try { + $counts = ['created' => 0, 'closed' => 0]; + PushQueue::setCloudTasksClientFactory(function ($className) use (&$counts) { + $counts['created']++; + return new class($counts) { + private $countsRef; + public function __construct(&$ref) { $this->countsRef = &$ref; } + public function batchCreateTasks($req) { + return new class { + public function isDone() { return true; } + public function pollUntilComplete() { return true; } + public function operationFailed() { return false; } + public function getMetadata() { + return new \Google\Cloud\Tasks\V2\BatchCreateTasksMetadata(); + } + public function getResult() { + $t1 = (new \Google\Cloud\Tasks\V2\Task()) + ->setName('projects/test-proj/locations/us-central1/queues/default/tasks/a'); + $t2 = (new \Google\Cloud\Tasks\V2\Task()) + ->setName('projects/test-proj/locations/us-central1/queues/default/tasks/b'); + return (new \Google\Cloud\Tasks\V2\BatchCreateTasksResponse()) + ->setTasks([$t1, $t2]); + } + }; + } + public function close() { $this->countsRef['closed']++; } + }; + }); + + $queue = new PushQueue('default'); + $names = $queue->addTasks([ + new PushTask('/a', [], ['name' => 'a']), + new PushTask('/b', [], ['name' => 'b']), + ]); + $this->assertEquals(['a', 'b'], $names); + $this->assertEquals(1, $counts['created']); + $this->assertEquals(1, $counts['closed']); + } finally { + PushQueue::setCloudTasksClientFactory(null); + putenv('APPENGINE_USE_CLOUDTASK_PUSH_QUEUE'); + putenv('GOOGLE_CLOUD_PROJECT'); + putenv('LOCATION_ID'); + } + } + }