diff --git a/.github/workflows/codespell_check.yaml b/.github/workflows/codespell_check.yaml index 1d4cd8a8d..9cca518fd 100644 --- a/.github/workflows/codespell_check.yaml +++ b/.github/workflows/codespell_check.yaml @@ -20,6 +20,6 @@ jobs: - name: Run CodeSpell run: | codespell --quiet-level=2 \ - --skip "./cpp/third_party,./style/codespell/ignore_words.txt,./.git" \ + --skip "./cpp/third_party,./php/vendor,./nodejs/node_modules,./style/codespell/ignore_words.txt,./.git" \ --ignore-words ./style/codespell/ignore_words.txt \ --exclude-file ./style/codespell/exclude_file.txt \ \ No newline at end of file diff --git a/.github/workflows/php_build.yml b/.github/workflows/php_build.yml index 61e3ef3f8..09ddc32f6 100644 --- a/.github/workflows/php_build.yml +++ b/.github/workflows/php_build.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ["7.4", "8.0", "8.1"] + php-version: ["8.1", "8.2", "8.3"] os: [ ubuntu-22.04, macos-11, windows-2022 ] steps: - name: Checkout @@ -17,9 +17,56 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} + extensions: grpc, protobuf - name: Validate composer.json working-directory: ./php run: composer validate - name: Install Dependencies working-directory: ./php - run: composer install + run: composer install --no-interaction --prefer-dist + - name: Run PHPUnit Tests + if: runner.os != 'Windows' + working-directory: ./php + run: vendor/bin/phpunit --testsuite "RocketMQ PHP Test Suite" --no-coverage + - name: Run PHPUnit Integration Tests + if: runner.os != 'Windows' + working-directory: ./php + run: vendor/bin/phpunit --testsuite "RocketMQ PHP Integration Tests" --no-coverage + - name: Run PHPUnit Tests (Windows) + if: runner.os == 'Windows' + working-directory: ./php + shell: bash + # Workaround: gRPC C extension may cause non-zero exit during PHP shutdown on + # Windows even when all tests pass. Only suppress the exit code when PHPUnit + # reports success ("OK" or "OK, but incomplete, skipped, or risky tests!", + # both printed only with zero failures/errors) but the process exits non-zero + # due to the gRPC issue. + run: | + set +e + vendor/bin/phpunit --testsuite "RocketMQ PHP Test Suite" --no-coverage > phpunit_output.txt 2>&1 + exit_code=$? + set -e + cat phpunit_output.txt + if [ "$exit_code" -ne 0 ] && grep -qE 'OK \([0-9]+ tests?|OK, but incomplete, skipped, or risky tests' phpunit_output.txt; then + echo "::warning::PHPUnit reported success but process exited with code $exit_code - likely gRPC shutdown issue on Windows" + exit 0 + fi + exit "$exit_code" + - name: Run PHPUnit Integration Tests (Windows) + if: runner.os == 'Windows' + working-directory: ./php + shell: bash + # Same gRPC shutdown workaround as the unit suite above. The integration + # suite skips real-broker tests in CI, so success prints + # "OK, but incomplete, skipped, or risky tests!" instead of "OK (N tests ...)". + run: | + set +e + vendor/bin/phpunit --testsuite "RocketMQ PHP Integration Tests" --no-coverage > phpunit_integration_output.txt 2>&1 + exit_code=$? + set -e + cat phpunit_integration_output.txt + if [ "$exit_code" -ne 0 ] && grep -qE 'OK \([0-9]+ tests?|OK, but incomplete, skipped, or risky tests' phpunit_integration_output.txt; then + echo "::warning::PHPUnit reported success but process exited with code $exit_code - likely gRPC shutdown issue on Windows" + exit 0 + fi + exit "$exit_code" diff --git a/README-CN.md b/README-CN.md index 96caedf95..b737c14ee 100644 --- a/README-CN.md +++ b/README-CN.md @@ -19,16 +19,16 @@ | 特性 | Java | C/C++ | C# | Golang | Rust | Python | Node.js | PHP | |------------------------------------------------| :---: |:------:|:-----:|:------:|:----:|:------:|:-------:| :---: | -| Producer with standard messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Producer with FIFO messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Producer with timed/delay messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Producer with transactional messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Producer with recalling timed/delay messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Simple consumer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Push consumer with concurrent message listener | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Push consumer with FIFO message listener | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Push consumer with FIFO consume accelerator | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Priority Message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | +| Producer with standard messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Producer with FIFO messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Producer with timed/delay messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Producer with transactional messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Producer with recalling timed/delay messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Simple consumer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Push consumer with concurrent message listener | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Push consumer with FIFO message listener | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Push consumer with FIFO consume accelerator | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Priority Message | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ## 先决条件和构建 diff --git a/README.md b/README.md index b7cf00ab2..777a91779 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,16 @@ Provide cloud-native and robust solutions for Java, C++, C#, Golang, Rust and al | Feature | Java | C/C++ | C# | Golang | Rust | Python | Node.js | PHP | | ---------------------------------------------- | :---: | :---: | :---: | :----: | :---: | :----: | :-----: | :---: | -| Producer with standard messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Producer with FIFO messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Producer with timed/delay messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Producer with transactional messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Producer with recalling timed/delay messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Simple consumer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Push consumer with concurrent message listener | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Push consumer with FIFO message listener | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Push consumer with FIFO consume accelerator | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | -| Priority Message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🚧 | +| Producer with standard messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Producer with FIFO messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Producer with timed/delay messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Producer with transactional messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Producer with recalling timed/delay messages | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Simple consumer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Push consumer with concurrent message listener | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Push consumer with FIFO message listener | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Push consumer with FIFO consume accelerator | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Priority Message | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ## Prerequisite and Build diff --git a/php/.gitignore b/php/.gitignore index 4e3d3c3ea..a853a6b12 100644 --- a/php/.gitignore +++ b/php/.gitignore @@ -2,3 +2,6 @@ src vendor composer.lock +coverage +.phpunit.result.cache +phpunit.xsd diff --git a/php/ClientConfiguration.php b/php/ClientConfiguration.php new file mode 100644 index 000000000..fdcd17a37 --- /dev/null +++ b/php/ClientConfiguration.php @@ -0,0 +1,168 @@ +endpoints = $endpoints; + $this->sessionCredentialsProvider = $sessionCredentialsProvider; + $this->requestTimeoutMs = $requestTimeoutMs; + $this->sslEnabled = $sslEnabled; + $this->namespace = $namespace; + $this->maxStartupAttempts = $maxStartupAttempts; + $this->tlsCredentials = $tlsCredentials; + } + + /** + * Factory method - only callable from ClientConfigurationBuilder. + * + * @param string $endpoints Target endpoint addresses. + * @param SessionCredentials|null $sessionCredentialsProvider Session credentials provider for authentication. + * @param int $requestTimeoutMs RPC request timeout in milliseconds. + * @param bool $sslEnabled Whether SSL is enabled. + * @param string $namespace Namespace for the client. + * @param int $maxStartupAttempts Maximum number of startup retry attempts. + * @param TlsCredentials|null $tlsCredentials TLS credentials for secure connections. + * @return ClientConfiguration New immutable configuration instance. + * + * @internal + */ + public static function create( + string $endpoints, + ?SessionCredentials $sessionCredentialsProvider, + int $requestTimeoutMs, + bool $sslEnabled, + string $namespace, + int $maxStartupAttempts, + ?TlsCredentials $tlsCredentials = null + ): ClientConfiguration { + return new self( + $endpoints, + $sessionCredentialsProvider, + $requestTimeoutMs, + $sslEnabled, + $namespace, + $maxStartupAttempts, + $tlsCredentials + ); + } + + /** + * Get the target endpoints address. + * + * @return string Target endpoint addresses. + */ + public function getEndpoints(): string + { + return $this->endpoints; + } + + /** + * Get the session credentials provider. + * + * @return SessionCredentials|null Session credentials provider, or null if not set. + */ + public function getSessionCredentialsProvider(): ?SessionCredentials + { + return $this->sessionCredentialsProvider; + } + + /** + * Get the RPC request timeout in milliseconds. + * + * @return int RPC request timeout in milliseconds. + */ + public function getRequestTimeoutMs(): int + { + return $this->requestTimeoutMs; + } + + /** + * Check whether SSL is enabled. + * + * @return bool True if SSL is enabled, false otherwise. + */ + public function isSslEnabled(): bool + { + return $this->sslEnabled; + } + + /** + * Get the namespace. + * + * @return string Namespace for the client. + */ + public function getNamespace(): string + { + return $this->namespace; + } + + /** + * Get the maximum number of startup retry attempts. + * + * @return int Maximum number of startup retry attempts. + */ + public function getMaxStartupAttempts(): int + { + return $this->maxStartupAttempts; + } + + /** + * Get the TLS credentials for gRPC connections. + * + * @return TlsCredentials|null TLS credentials, or null if not set. + */ + public function getTlsCredentials(): ?TlsCredentials + { + return $this->tlsCredentials; + } +} diff --git a/php/ClientConfigurationBuilder.php b/php/ClientConfigurationBuilder.php new file mode 100644 index 000000000..611da3a99 --- /dev/null +++ b/php/ClientConfigurationBuilder.php @@ -0,0 +1,238 @@ +endpoints = $endpoints; + return $this; + } + + /** + * Set authentication credentials provider. + * + * @param SessionCredentials|null $credentials + * @return $this + */ + public function setCredentialProvider(?SessionCredentials $credentials): self + { + $this->sessionCredentialsProvider = $credentials; + return $this; + } + + /** + * Set RPC request timeout. + * + * @param int $timeoutMs Timeout in milliseconds (default 3000) + * @return $this + * @throws \InvalidArgumentException if timeout is zero or negative + */ + public function setRequestTimeout(int $timeoutMs): self + { + if ($timeoutMs <= 0) { + throw new \InvalidArgumentException("Request timeout must be > 0"); + } + $this->requestTimeoutMs = $timeoutMs; + return $this; + } + + /** + * Enable or disable SSL. + * + * @param bool $enabled Default true + * @return $this + */ + public function enableSsl(bool $enabled = true): self + { + $this->sslEnabled = $enabled; + return $this; + } + + /** + * Set namespace. + * + * @param string $namespace Default empty + * @return $this + */ + public function setNamespace(string $namespace): self + { + $this->namespace = $namespace; + return $this; + } + + /** + * Set max startup retry attempts. + * + * @param int $attempts Must be > 0 + * @return $this + * @throws \InvalidArgumentException if attempts is zero or negative + */ + public function setMaxStartupAttempts(int $attempts): self + { + if ($attempts <= 0) { + throw new \InvalidArgumentException("Max startup attempts must be > 0"); + } + $this->maxStartupAttempts = $attempts; + return $this; + } + + /** + * Set TLS credentials for the gRPC connection. + * + * @param TlsCredentials $tlsCredentials + * @return $this + */ + public function setTlsCredentials(TlsCredentials $tlsCredentials): self + { + $this->tlsCredentials = $tlsCredentials; + $this->sslEnabled = true; + return $this; + } + + /** + * Enable TLS with default system CA bundle. + * + * @return $this + */ + public function enableTls(): self + { + $this->tlsCredentials = TlsCredentials::createDefault(); + $this->sslEnabled = true; + return $this; + } + + /** + * Enable mutual TLS (mTLS) with client certificate and key. + * + * @param string $clientCertPath Path to client certificate file + * @param string $clientKeyPath Path to client private key file + * @param string|null $caCertPath Optional CA certificate path + * @return $this + */ + public function enableMutualTls( + string $clientCertPath, + string $clientKeyPath, + ?string $caCertPath = null + ): self { + $this->tlsCredentials = TlsCredentials::createMtls( + $clientCertPath, + $clientKeyPath, + $caCertPath + ); + $this->sslEnabled = true; + return $this; + } + + /** + * Enable TLS but skip peer verification (development only). + * + * ⚠️ SECURITY WARNING: This method is for internal testing use ONLY. + * It should NOT be used in production code as it allows man-in-the-middle attacks. + * + * For development/testing, use environment variables or configuration files instead: + * - Set ROCKETMQ_TLS_SKIP_VERIFY=true in your .env file + * - Use TlsCredentials::createInsecureDev() directly with explicit awareness of risks + * + * @return $this + * @internal Not intended for public API usage + * @deprecated Will be removed in future versions. Use environment-based configuration instead. + */ + public function disableTlsVerification(): self + { + // Multi-channel runtime warning (fires only once per process) + if (!self::$tlsWarningIssued) { + self::$tlsWarningIssued = true; + + $msg = "SECURITY WARNING: ClientConfigurationBuilder::disableTlsVerification() " . + "disables TLS certificate verification. This is vulnerable to MITM attacks. " . + "NEVER use in production. Use TlsCredentials::createInsecureDev() instead."; + + // 1. PHP deprecation notice (visible in error handlers / PHPUnit) + trigger_error($msg, E_USER_DEPRECATED); + + // 2. stderr output (visible in CLI / Docker / systemd logs) + if (defined('STDERR')) { + fwrite(STDERR, "\033[31m[ROCKETMQ] {$msg}\033[0m\n"); + } + + // 3. Application logger at ERROR level (highest available) + Logger::getInstance('ClientConfiguration')->error( + "TLS certificate verification DISABLED. " . + "Connection is vulnerable to man-in-the-middle attacks. " . + "This must ONLY be used in isolated dev/test environments." + ); + + // 4. PHP error_log (SAPI log / syslog / web server error log) + error_log("[RocketMQ] {$msg}"); + } + + $this->tlsCredentials = TlsCredentials::createInsecureDev(); + $this->sslEnabled = true; + return $this; + } + + /** + * Build the immutable ClientConfiguration object. + * + * @return ClientConfiguration + * @throws \InvalidArgumentException if endpoints not set + */ + public function build(): ClientConfiguration + { + if ($this->endpoints === null || $this->endpoints === '') { + throw new \InvalidArgumentException("Endpoints must be set"); + } + if ($this->requestTimeoutMs <= 0) { + throw new \InvalidArgumentException("Request timeout must be > 0"); + } + + return ClientConfiguration::create( + $this->endpoints, + $this->sessionCredentialsProvider, + $this->requestTimeoutMs, + $this->sslEnabled, + $this->namespace, + $this->maxStartupAttempts, + $this->tlsCredentials + ); + } +} diff --git a/php/ClientConstants.php b/php/ClientConstants.php new file mode 100644 index 000000000..b1ce07997 --- /dev/null +++ b/php/ClientConstants.php @@ -0,0 +1,46 @@ +code = $code; + parent::__construct($message ?: "Client error with code: {$code}", $code); + } + + /** + * Get the error status code. + * + * @return int The error status code + */ + public function getStatusCode(): int + { + return $this->code; + } +} + +class BadRequestException extends ClientException {} +class UnauthorizedException extends ClientException {} +class PaymentRequiredException extends ClientException {} +class ForbiddenException extends ClientException {} +class NotFoundException extends ClientException {} +class PayloadTooLargeException extends ClientException {} +class PayloadEmptyException extends ClientException {} +class TooManyRequestsException extends ClientException {} +class LiteTopicQuotaExceededException extends ClientException {} +class LiteSubscriptionQuotaExceededException extends ClientException {} +class RequestHeaderFieldsTooLargeException extends ClientException {} +class InternalErrorException extends ClientException {} +class ProxyTimeoutException extends ClientException {} +class UnsupportedException extends ClientException {} diff --git a/php/ClientMetrics.php b/php/ClientMetrics.php new file mode 100644 index 000000000..8740f5f4c --- /dev/null +++ b/php/ClientMetrics.php @@ -0,0 +1,270 @@ +startTime = time(); + } + + /** + * Get the singleton instance of ClientMetrics. + * + * @return ClientMetrics The singleton ClientMetrics instance + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Reset the singleton instance to null. + * + * @return void + */ + public static function reset(): void + { + self::$instance = null; + } + + /** + * Record a send operation with success flag and latency. + * + * @param bool $success Whether the send operation succeeded + * @param int $latencyMs Send latency in milliseconds + * @return void + */ + public function recordSend(bool $success = true, int $latencyMs = 0): void + { + $this->sendCount++; + if (!$success) { + $this->sendErrorCount++; + } + $this->sendLatencyMs[] = $latencyMs; + // Keep only last 1000 entries + if (count($this->sendLatencyMs) > 1000) { + $this->sendLatencyMs = array_slice($this->sendLatencyMs, -500); + } + } + + /** + * Record a receive operation with success flag. + * + * @param bool $success Whether the receive operation succeeded + * @return void + */ + public function recordReceive(bool $success = true): void + { + $this->receiveCount++; + if (!$success) { + $this->receiveErrorCount++; + } + } + + /** + * Record a consume operation with success flag. + * + * @param bool $success Whether the consume operation succeeded + * @return void + */ + public function recordConsume(bool $success = true): void + { + if ($success) { + $this->consumeOkCount++; + } else { + $this->consumeErrorCount++; + } + } + + /** + * Record an ack operation with success flag. + * + * @param bool $success Whether the ack operation succeeded + * @return void + */ + public function recordAck(bool $success = true): void + { + $this->ackCount++; + if (!$success) { + $this->ackErrorCount++; + } + } + + /** + * Get total send count. + * + * @return int Total number of send operations recorded + */ + public function getSendCount(): int { return $this->sendCount; } + + /** + * Get send error count. + * + * @return int Total number of failed send operations + */ + public function getSendErrorCount(): int { return $this->sendErrorCount; } + + /** + * Get total receive count. + * + * @return int Total number of receive operations recorded + */ + public function getReceiveCount(): int { return $this->receiveCount; } + + /** + * Get receive error count. + * + * @return int Total number of failed receive operations + */ + public function getReceiveErrorCount(): int { return $this->receiveErrorCount; } + + /** + * Get successful consume count. + * + * @return int Total number of successful consume operations + */ + public function getConsumeOkCount(): int { return $this->consumeOkCount; } + + /** + * Get consume error count. + * + * @return int Total number of failed consume operations + */ + public function getConsumeErrorCount(): int { return $this->consumeErrorCount; } + + /** + * Get total ack count. + * + * @return int Total number of ack operations recorded + */ + public function getAckCount(): int { return $this->ackCount; } + + /** + * Get ack error count. + * + * @return int Total number of failed ack operations + */ + public function getAckErrorCount(): int { return $this->ackErrorCount; } + + /** + * Get the average send latency in milliseconds. + * + * @return float Average send latency across all recorded send operations + */ + public function getAverageSendLatencyMs(): float + { + if (empty($this->sendLatencyMs)) { + return 0.0; + } + return array_sum($this->sendLatencyMs) / count($this->sendLatencyMs); + } + + /** + * Get the uptime in seconds since instance creation. + * + * @return int Number of seconds elapsed since the ClientMetrics instance was created + */ + public function getUptimeSeconds(): int + { + return time() - $this->startTime; + } + + /** + * Get a snapshot of all metrics statistics. + * + * @return array Associative array containing all current metrics values + */ + public function getStats(): array + { + return [ + 'uptimeSeconds' => $this->getUptimeSeconds(), + 'sendCount' => $this->sendCount, + 'sendErrorCount' => $this->sendErrorCount, + 'avgSendLatencyMs' => round($this->getAverageSendLatencyMs(), 2), + 'receiveCount' => $this->receiveCount, + 'receiveErrorCount' => $this->receiveErrorCount, + 'consumeOkCount' => $this->consumeOkCount, + 'consumeErrorCount' => $this->consumeErrorCount, + 'ackCount' => $this->ackCount, + 'ackErrorCount' => $this->ackErrorCount, + ]; + } +} + +class MetricsInterceptor implements MessageInterceptor +{ + private ClientMetrics $metrics; + + /** + * Construct a metrics interceptor and attach the singleton ClientMetrics. + */ + public function __construct() + { + $this->metrics = ClientMetrics::getInstance(); + } + + /** + * Intercept a message hook point and record the corresponding metric. + * + * @param string $hookPoint The hook point identifier (one of MessageHookPoints constants) + * @param array $context Context data including success flag and optional latency + * @return void + */ + public function intercept(string $hookPoint, array $context = []): void + { + match ($hookPoint) { + MessageHookPoints::SEND => $this->metrics->recordSend($context['success'] ?? true, $context['latencyMs'] ?? 0), + MessageHookPoints::RECEIVE => $this->metrics->recordReceive($context['success'] ?? true), + MessageHookPoints::CONSUME => $this->metrics->recordConsume($context['success'] ?? true), + MessageHookPoints::ACK => $this->metrics->recordAck($context['success'] ?? true), + default => null + }; + } + + /** + * Get the ClientMetrics instance attached to this interceptor. + * + * @return ClientMetrics The singleton metrics instance used by this interceptor + */ + public function getMetrics(): ClientMetrics + { + return $this->metrics; + } +} diff --git a/php/ClientTrait.php b/php/ClientTrait.php new file mode 100644 index 000000000..8aab9a2be --- /dev/null +++ b/php/ClientTrait.php @@ -0,0 +1,197 @@ +getCredentials(), + $this->getClientIdValue(), + ClientConstants::LANGUAGE, + ClientConstants::CLIENT_VERSION, + $this->getNamespaceValue(), + 'v2' + ); + + // Set gRPC deadline if timeout is provided + if ($timeoutMs !== null && $timeoutMs > 0) { + // Convert milliseconds to microseconds for gRPC deadline + $timeoutUs = $timeoutMs * 1000; + $metadata['grpc-timeout'] = [$timeoutUs . 'u']; // microseconds format, array-wrapped for gRPC + } + + return $metadata; + } + + /** + * Parse endpoints string into protobuf Endpoints object. + * + * @param string $endpoints e.g. "127.0.0.1:8080" or "example.com:8080" + * @return Endpoints + */ + protected function parseEndpoints(string $endpoints): Endpoints + { + $cleaned = match (true) { + str_starts_with($endpoints, 'https://') => substr($endpoints, 8), + str_starts_with($endpoints, 'http://') => substr($endpoints, 7), + default => $endpoints, + }; + + $lastColon = strrpos($cleaned, ':'); + if ($lastColon !== false) { + $host = substr($cleaned, 0, $lastColon); + $port = (int)substr($cleaned, $lastColon + 1); + } else { + $host = $cleaned; + $port = 80; + } + + $scheme = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false + ? AddressScheme::IPv4 + : AddressScheme::DOMAIN_NAME; + + $address = new Address(); + $address->setHost($host); + $address->setPort($port); + + $endpointsObj = new Endpoints(); + $endpointsObj->setScheme($scheme); + $endpointsObj->setAddresses([$address]); + + return $endpointsObj; + } + + /** + * Extract receipt handle from a message view. + * + * @param MessageViewInterface|object $messageView + * @return string|null + */ + protected function extractReceiptHandle($messageView): ?string + { + if ($messageView instanceof MessageViewInterface) { + $sysProps = $messageView->getSystemProperties(); + return $sysProps?->getReceiptHandle(); + } + return null; + } + + /** + * Extract message ID from a message view. + * + * @param object $messageView + * @return string|null + */ + protected function extractMessageId($messageView): ?string + { + if ($messageView instanceof MessageViewInterface) { + return $messageView->getMessageId(); + } + return null; + } + + /** + * Extract topic name from a message view. + * + * @param MessageViewInterface|object $messageView + * @return string|null + */ + protected function extractTopic($messageView): ?string + { + if ($messageView instanceof MessageViewInterface) { + $topic = $messageView->getTopic(); + return $topic !== '' ? $topic : null; + } + return null; + } + + /** + * Get call options for gRPC calls. + * + * @param int|null $overrideTimeout Optional timeout to override default (in microseconds) + * @return array Array with 'timeout' key for gRPC call options + */ + protected function getCallOptions(?int $overrideTimeout = null): array + { + return ['timeout' => $overrideTimeout ?? ClientConstants::GRPC_DEFAULT_TIMEOUT]; + } + + /** + * Get operation-specific timeout from constants. + * + * @param string $operation Operation name (e.g., 'SEND_MESSAGE', 'ACK_MESSAGE') + * @return int Timeout in microseconds + */ + protected function getOperationTimeout(string $operation): int + { + return match ($operation) { + 'SEND_MESSAGE' => ClientConstants::GRPC_SEND_MESSAGE_TIMEOUT, + 'ACK_MESSAGE' => ClientConstants::GRPC_ACK_MESSAGE_TIMEOUT, + 'QUERY_ROUTE' => ClientConstants::GRPC_QUERY_ROUTE_TIMEOUT, + 'HEARTBEAT' => ClientConstants::GRPC_HEARTBEAT_TIMEOUT, + 'END_TRANSACTION' => ClientConstants::GRPC_END_TRANSACTION_TIMEOUT, + 'CHANGE_INVISIBLE' => ClientConstants::GRPC_CHANGE_INVISIBLE_TIMEOUT, + 'RECALL_MESSAGE' => ClientConstants::GRPC_RECALL_MESSAGE_TIMEOUT, + default => ClientConstants::GRPC_DEFAULT_TIMEOUT, + }; + } + + /** + * Extract lite topic from a message view's system properties. + * + * @param MessageViewInterface|object $messageView + * @return string|null + */ + protected function extractLiteTopic($messageView): ?string + { + if ($messageView instanceof MessageViewInterface) { + $sysProps = $messageView->getSystemProperties(); + if ($sysProps !== null && $sysProps->hasLiteTopic()) { + return $sysProps->getLiteTopic(); + } + } + return null; + } +} diff --git a/php/ClientTraitProvider.php b/php/ClientTraitProvider.php new file mode 100644 index 000000000..9fe9048e5 --- /dev/null +++ b/php/ClientTraitProvider.php @@ -0,0 +1,33 @@ +suspendTimeMs = $suspendTimeMs; + } + + /** + * Create a suspend result with the given delay. + * + * @param int $suspendTimeMs Duration in milliseconds to suspend before retry + * @return ConsumeResultSuspend + */ + public static function of(int $suspendTimeMs): ConsumeResultSuspend + { + return new self($suspendTimeMs); + } + + /** + * Get the suspend time in milliseconds. + * + * @return int Suspend duration in milliseconds + */ + public function getSuspendTimeMs(): int + { + return $this->suspendTimeMs; + } + + /** + * Get the suspend time as a named constant (SUSPEND=2). + * @return int The suspend constant value (SUSPEND = 2) + */ + public function getValue(): int + { + return self::SUSPEND; + } +} diff --git a/php/ConsumeService.php b/php/ConsumeService.php new file mode 100644 index 000000000..6c67ae947 --- /dev/null +++ b/php/ConsumeService.php @@ -0,0 +1,800 @@ +logger = $logger; + $this->messageListener = $messageListener instanceof \Closure + ? $messageListener + : \Closure::fromCallable($messageListener); + $this->consumer = $consumer; + } + + /** + * Consume messages from the given ProcessQueue. + * + * @param ProcessQueue $pq + * @return void + */ + abstract public function consume(ProcessQueue $pq): void; + + /** + * Dispatch a single message to the user listener. + * + * @param object $messageView + * @return mixed ConsumeResult::SUCCESS, ConsumeResult::FAILURE, ConsumeResultSuspend::SUSPEND, or the ConsumeResultSuspend instance + */ + public function consumeMessage(object $messageView): mixed + { + try { + $result = call_user_func($this->messageListener, $messageView); + + // Handle ConsumeResultSuspend + if ($result instanceof ConsumeResultSuspend) { + $this->consumer->executeInterceptors(MessageHookPoints::CONSUME, [ + 'success' => false, + 'messageId' => $this->extractMessageId($messageView), + 'topic' => $this->extractTopic($messageView), + ]); + // Return the ConsumeResultSuspend instance with suspend time info + return $result; + } + + // Normalize int/enum to ConsumeResult enum + $consumeResult = ConsumeResult::fromMixed($result); + $success = $consumeResult !== ConsumeResult::FAILURE; + + $this->consumer->executeInterceptors(MessageHookPoints::CONSUME, [ + 'success' => $success, + 'messageId' => $this->extractMessageId($messageView), + 'topic' => $this->extractTopic($messageView), + ]); + + return $consumeResult; + } catch (\Throwable $e) { + $this->logger->warning("ConsumeService listener threw exception: " . $e->getMessage()); + + $this->consumer->executeInterceptors(MessageHookPoints::CONSUME, [ + 'success' => false, + 'messageId' => $this->extractMessageId($messageView), + 'topic' => $this->extractTopic($messageView), + ]); + + return ConsumeResult::FAILURE; + } + } + + /** + * ACK a message via gRPC.Retries up to 3 times an failure + * + * @param object $messageView + * @return bool true if successful, false if skipped or all retries exhausted + * @throws \RuntimeException If gRPC call fails after all retries + */ + public function ackMessage(object $messageView): bool + { + $receiptHandle = $this->extractReceiptHandle($messageView); + $messageId = $this->extractMessageId($messageView); + $topic = $this->extractTopic($messageView); + + if (!$receiptHandle) { + $this->logger->warning("ConsumeService ackMessage: no receipt handle, skipping messageId={$messageId}"); + return false; + } + + $namespace = $this->consumer->getNamespace(); + $groupResource = $this->consumer->getGroupResourceWithNamespace(); + $topicResource = $this->consumer->getTopicResource($topic); + + $entry = new AckMessageEntry(); + if ($messageId) { + $entry->setMessageId($messageId); + } + $entry->setReceiptHandle($receiptHandle); + + if ($messageView instanceof MessageViewInterface) { + $sysProps = $messageView->getSystemProperties(); + if ($sysProps !== null && $sysProps->hasLiteTopic()) { + $entry->setLiteTopic($sysProps->getLiteTopic()); + } + } + + $request = new AckMessageRequest(); + $request->setGroup($groupResource); + $request->setTopic($topicResource); + $request->setEntries([$entry]); + + $metadata = $this->buildMetadata(ClientConstants::GRPC_DEFAULT_TIMEOUT / 1000); + + $brokerClient = $this->getBrokerClient($messageView); + $maxRetries = 3; + $attempt = 0; + while ($attempt < $maxRetries) { + try { + list($response, $status) = $brokerClient->AckMessage($request, $metadata, $this->getCallOptions())->wait(); + if ($status->code !== 0) { + $this->logger->warning("ConsumeService ackMessage attempt {$attempt}: status error: " . $status->details); + } elseif ($response->hasStatus()) { + $statusCode = $response->getStatus()->getCode(); + if ($statusCode === 20000) { + $this->logger->debug("ConsumeService ackMessage success for messageId={$messageId}"); + $this->executeAckInterceptor(true, $messageId, $topic); + return true; + } + if ($statusCode == 40003) { + $this->logger->warning("ConsumeService ackMessage invalid receipt handle, giving up"); + $this->executeAckInterceptor(false, $messageId, $topic); + return false; + } + $this->logger->warning("ConsumeService ackMessage attempt {$attempt}: error code={$statusCode}, retrying"); + } else { + $this->logger->warning("ConsumeService ackMessage attempt {$attempt} response missing status, retrying"); + } + } catch (\Exception $e) { + $this->logger->warning("ConsumeService ackMessage attempt {$attempt} failed: " . $e->getMessage()); + } + + $attempt++; + if ($attempt < $maxRetries) { + SwooleCompat::sleepBlocking(1000000 * $attempt, fn($msg) => $this->logger->debug("ackMessage retry: {$msg}")); // linear backoff: 1s, 2s + } + } + $this->logger->warning("ConsumeService ackMessage exhausted {$maxRetries} retries for messageId={$messageId}"); + return false; + } + + /** + * NACK a message (change invisible duration for retry).Retries up to 3 times on failure. + * + * @param object $messageView + * @param int $deliveryAttempt Current delivery attempt number + * @param int|null $invisibleDuration Override invisible duration in seconds (for ConsumeResultSuspend) + * @return bool true if NACK succeeded, false if skipped or all retries exhausted + * @throws \RuntimeException If gRPC call fails after all retries + */ + public function nackMessage(object $messageView, int $deliveryAttempt = 1, ?int $invisibleDuration = null): bool + { + $receiptHandle = $this->extractReceiptHandle($messageView); + $messageId = $this->extractMessageId($messageView); + $topic = $this->extractTopic($messageView); + + if (!$receiptHandle) { + $this->logger->warning("ConsumeService nackMessage: no receipt handle, skipping"); + return false; + } + + // Calculate retry delay: exponential backoff with cap at 30s, or use provided suspend time + if ($invisibleDuration !== null) { + $delaySeconds = $invisibleDuration; + } else { + $retryPolicy = $this->consumer->getRetryPolicy(); + if ($retryPolicy instanceof RetryPolicyInterface) { + $delayMs = $retryPolicy->getNextAttemptDelayMs($deliveryAttempt); + $delaySeconds = max(1, (int)ceil($delayMs / 1000)); + } else { + $delaySeconds = min(pow(2, $deliveryAttempt - 1) * 10, 30); + } + } + if ($delaySeconds < 1) { + $delaySeconds = 1; + } + + $groupResource = $this->consumer->getGroupResourceWithNamespace(); + $topicResource = $this->consumer->getTopicResource($topic); + + $duration = new Duration(); + $duration->setSeconds($delaySeconds); + $duration->setNanos(0); + + $request = new ChangeInvisibleDurationRequest(); + $request->setGroup($groupResource); + $request->setTopic($topicResource); + $request->setReceiptHandle($receiptHandle); + $request->setInvisibleDuration($duration); + if ($messageId) { + $request->setMessageId($messageId); + } + + $sysProps = $messageView->getSystemProperties(); + if ($sysProps !== null && $sysProps->hasLiteTopic()) { + $request->setLiteTopic($sysProps->getLiteTopic()); + $request->setSuspend(true); + } + + $metadata = $this->buildMetadata(ClientConstants::GRPC_DEFAULT_TIMEOUT / 1000); + + $brokerClient = $this->getBrokerClient($messageView); + $maxRetries = 3; + $attempt = 0; + while ($attempt < $maxRetries) { + try { + list($response, $status) = $brokerClient->ChangeInvisibleDuration($request, $metadata, $this->getCallOptions())->wait(); + if ($status->code !== 0) { + $this->logger->warning("ConsumeService nackMessage attempt {$attempt}: status error: " . $status->details); + } elseif ($response->hasStatus()) { + $statusCode = $response->getStatus()->getCode(); + if ($statusCode === 20000) { + $this->logger->debug("ConsumeService nackMessage success for messageId={$messageId}"); + $this->executeNackInterceptor(true, $messageId, $topic, $deliveryAttempt, $delaySeconds); + return true; + } + if ($statusCode == 40003) { + $this->logger->warning("ConsumeService nackMessage invalid receipt handle, giving up"); + $this->executeNackInterceptor(false, $messageId, $topic, $deliveryAttempt, $delaySeconds); + return false; + } + $this->logger->warning("ConsumeService nackMessage attempt {$attempt}: error code={$statusCode}, retrying"); + } else { + $this->logger->warning("ConsumeService nackMessage attempt {$attempt} response missing status, retrying"); + } + } catch (\Exception $e) { + $this->logger->warning("ConsumeService nackMessage attempt {$attempt} failed: " . $e->getMessage()); + } + $attempt++; + if ($attempt < $maxRetries) { + SwooleCompat::sleepBlocking(1000000 * $attempt, fn($msg) => $this->logger->debug("nackMessage retry: {$msg}")); + } + } + $this->logger->warning("ConsumeService nackMessage exhausted {$maxRetries} retries for messageId={$messageId}"); + return false; + } + + /** + * Forward a message to the dead letter queue.Retries up to 3 times on failure. + * + * @param object $messageView + * @param int|null $deliveryAttempt Current delivery attempt number + * @return bool true if DLQ forward succeeded, false if skipped or all retries exhausted + * @throws \RuntimeException If gRPC call fails after all retries + */ + public function forwardToDeadLetterQueue(object $messageView, ?int $deliveryAttempt = null): bool + { + $receiptHandle = $this->extractReceiptHandle($messageView); + $messageId = $this->extractMessageId($messageView); + $topic = $this->extractTopic($messageView); + $liteTopic = $this->extractLiteTopic($messageView); + + if (!$receiptHandle) { + $this->logger->warning("ConsumeService forwardToDeadLetterQueue: no receipt handle, skipping"); + return false; + } + + $groupResource = $this->consumer->getGroupResourceWithNamespace(); + $topicResource = $this->consumer->getTopicResource($topic); + + $request = new ForwardMessageToDeadLetterQueueRequest(); + $request->setGroup($groupResource); + $request->setTopic($topicResource); + $request->setReceiptHandle($receiptHandle); + if ($messageId) { + $request->setMessageId($messageId); + } + if ($liteTopic !== null) { + $request->setLiteTopic($liteTopic); + } + $actualAttempt = $deliveryAttempt ?? $this->maxAttempts; + $request->setDeliveryAttempt($actualAttempt); + $request->setMaxDeliveryAttempts($this->maxAttempts); + + $metadata = $this->buildMetadata(ClientConstants::GRPC_DEFAULT_TIMEOUT / 1000); + $brokerClient = $this->getBrokerClient($messageView); + $maxRetries = 3; + $attempt = 0; + while ($attempt < $maxRetries) { + try { + list($response, $status) = $brokerClient->ForwardMessageToDeadLetterQueue($request, $metadata, $this->getCallOptions())->wait(); + if ($status->code !== 0) { + $this->logger->warning("ConsumeService forwardToDeadLetterQueue attempt {$attempt}: " . $status->details); + } else { + $this->logger->info("ConsumeService forwardToDeadLetterQueue success for messageId={$messageId}"); + $this->executeDLQInterceptor(true, $messageId, $topic); + return true; + } + } catch (\Exception $e) { + $this->logger->error("ConsumeService forwardToDeadLetterQueue exception: " . $e->getMessage()); + } + $attempt++; + if ($attempt < $maxRetries) { + SwooleCompat::sleepBlocking(1000000 * $attempt, fn($msg) => $this->logger->debug("forwardToDeadLetterQueue retry: {$msg}")); + } + } + $this->logger->error("ConsumeService forwardToDeadLetterQueue exhausted {$maxRetries} retries for messageId={$messageId}"); + return false; + } + + /** + * Execute NACK interceptor hook. Overridden by FifoConsumeService. + * + * @param bool $success Whether the NACK was successful + * @param string $messageId Message ID being NACKed + * @param string $topic Topic name + * @param int $deliveryAttempt Current delivery attempt count + * @param int $delaySeconds Delay in seconds before next delivery + * @return void + */ + protected function executeNackInterceptor(bool $success, string $messageId, string $topic, int $deliveryAttempt, int $delaySeconds): void + { + + } + + /** + * Execute DLQ interceptor hook. Overridden by FifoConsumeService. + * + * @param bool $success Whether the DLQ forward was successful + * @param string $messageId Message ID being forwarded + * @param string $topic Topic name + * @param int $deliveryAttempt Current delivery attempt count + * @param int $delaySeconds Delay in seconds before DLQ forwarding + * @return void + */ + protected function executeDLQInterceptor(bool $success, string $messageId, string $topic, ?int $deliveryAttempt = null, ?int $delaySeconds = null): void + { + + } + + /** + * Get the gRPC broker client for the given message's endpoint. + * + * @param object $messageView + * @return object gRPC client instance + */ + private function getBrokerClient(object $messageView): object + { + $endpoints = $messageView->getEndpoints(); + if ($endpoints !== null) { + $addresses = $endpoints->getAddresses(); + $addressesArray = ProtobufUtil::repeatedFieldToArray($addresses); + if (!empty($addressesArray) && $addressesArray[0] !== null) { + $address = $addressesArray[0]; + + $brokerKey = $address->getHost() . ':' . $address->getPort(); + $this->logger->debug("ConsumerService getBrokerClient : routing to broker {$brokerKey}"); + + // Use the same credentials as the consumer (inherited from ClientConfiguration) + // Don't hardcode insecure credentials - let RpcClientManager handle defaults + return RpcClientManager::getInstance()->getClient($brokerKey); + } + } + return $this->consumer->getClient(); + } + + /** + * Execute ACK interceptor on consumer. + * + * @param bool $success Whether the ACK was successful + * @param string $messageId Message ID being ACKed + * @param string $topic Topic name + * @return void + */ + private function executeAckInterceptor(bool $success, string $messageId, string $topic): void + { + $this->consumer->executeInterceptors(MessageHookPoints::ACK, [ + 'success' => $success, + 'messageId' => $messageId, + 'topic' => $topic, + ]); + } + + // ClientTrait required methods + /** + * Get session credentials from the consumer. + * + * @return SessionCredentials|null + */ + protected function getCredentials(): ?SessionCredentials { + return $this->consumer->getSessionCredentials(); + } + /** + * Get the client ID from the consumer. + * + * @return string + */ + protected function getClientIdValue(): string { return $this->consumer->getClientId(); } + /** + * Get the namespace from the consumer. + * + * @return string + */ + protected function getNamespaceValue(): string { return $this->consumer->getNamespace(); } +} + +/** + * StandardConsumeService - Sequential message consumption (Standard mode). + * + * Iterates through cached messages, invokes the listener for each sequentially, + * then acks or nacks based on the result. + */ +class StandardConsumeService extends ConsumeService +{ + /** + * Consume messages sequentially from the process queue. + * + * @param ProcessQueue $pq + * @return void + */ + public function consume(ProcessQueue $pq): void + { + $messages = $pq->getCachedMessages(); + if (empty($messages)) { + return; + } + + // Copy messages list to avoid modification during iteration + $toConsume = array_values($messages); + $this->logger->debug("StandardConsumeService consuming " . count($toConsume) . " messages from queue"); + + foreach ($toConsume as $messageView) { + // Check if message was already evicted by a previous iteration + if ($pq->isDropped()) { + break; + } + + if ($messageView->isCorrupted()) { + $messageId = $messageView->getMessageId() ?: 'unknown'; + $this->logger->error("StandardConsumeService: Message $messageId is corrupted"); + $pq->discardMessage($messageView); + continue; + } + $messageId = $messageView->getMessageId() ?: 'unknown'; + + $result = $this->consumeMessage($messageView); + + if ($result instanceof ConsumeResultSuspend) { + $suspendSec = (int)ceil($result->getSuspendTimeMs() / 1000); + $this->logger->debug('StandardConsumerService suspend for %d seconds, messageId: %s', $suspendSec, $messageId); + $this->nackMessage($messageView, 1, $suspendSec); + $pq->evictMessage($messageView); + } elseif ($result === \Apache\Rocketmq\ConsumeResult::SUCCESS) { + $this->logger->debug('StandardConsumerService consume success, messageId: %s', $messageId); + $this->ackMessage($messageView); + $pq->evictMessage($messageView); + } else { + $deliveryAttempt = $messageView->getDeliveryAttempt(); + if ($deliveryAttempt >= $this->maxAttempts) { + $this->logger->debug('StandardConsumerService consume failed, messageId: %s, deliveryAttempt: %s , forwarding to DLQ', $messageId, $deliveryAttempt); + $this->forwardToDeadLetterQueue($messageView, $deliveryAttempt); + $pq->evictMessage($messageView); + } else { + $this->logger->debug('StandardConsumerService consume failed, messageId: %s, attempt %d', $messageId, $deliveryAttempt); + $this->nackMessage($messageView); + $messageView->incrementDeliveryAttempt(); + } + } + } + } +} + +/** + * FifoConsumeService - Strict FIFO order consumption. + * + * When enableFifoConsumeAccelerator is false: all messages consumed sequentially. + * When enableFifoConsumeAccelerator is true: messages grouped by messageGroup key, + * each group processed sequentially, but different groups in parallel. + */ +class FifoConsumeService extends ConsumeService +{ + private bool $enableFifoConsumeAccelerator = false; + + /** + * @param Logger $logger Logger instance + * @param callable $messageListener User callback + * @param object $consumer Reference to PushConsumer + * @param bool $enableFifoConsumeAccelerator Whether to enable FIFO consume accelerator + */ + public function __construct(Logger $logger, callable $messageListener, ConsumerInterface $consumer, $enableFifoConsumeAccelerator = false) + { + parent::__construct($logger, $messageListener, $consumer); + $this->enableFifoConsumeAccelerator = $enableFifoConsumeAccelerator; + } + + /** + * Get the group key for a message. Override in subclasses (e.g., liteTopic). + * + * @param object $messageView + * @return string + */ + protected function getMessageGroupKey(object $messageView): string + { + $sysProps = $messageView->getSystemProperties(); + if ($sysProps !== null && $sysProps->hasMessageGroup()) { + return $sysProps->getMessageGroup(); + } + return 'default'; + } + + /** + * Consume messages from the process queue preserving FIFO order. + * + * @param ProcessQueue $pq + * @return void + */ + public function consume(ProcessQueue $pq): void + { + $messages = $pq->getCachedMessages(); + if (empty($messages)) { + return; + } + + if ($pq->isDropped()) { + return; + } + + if ($this->enableFifoConsumeAccelerator && count($messages) > 1) { + $this->consumeWithAccelerator($pq, $messages); + } else { + $this->consumeSequentially($pq, $messages); + } + } + + /** + * Sequential consumption (original behavior, accelerator disabled). + * + * @param ProcessQueue $pq + * @param array $messages Array of message views + * @return void + */ + private function consumeSequentially(ProcessQueue $pq, array $messages): void + { + // Only consume the first message (head of queue) to preserve FIFO order + $messageView = reset($messages); + + if ($pq->isDropped()) { + return; + } + + if ($messageView->isCorrupted()) { + $messageId = $messageView->getMessageId() ?: 'unknown'; + $this->logger->error("FifoConsumeService: Message $messageId is corrupted"); + $pq->discardFifoMessage($messageView); + $next = next($messages); + if ($next !== false) { + $this->consumeSequentially($pq, $messages); + } + return; + } + $this->consumeFifoIteratively($pq, $messageView, 1); + } + + /** + * Accelerated FIFO consumption: group by messageGroup, process groups in parallel. + * Each group is still processed one-at-a-time internally, but groups run concurrently. + * + * @param ProcessQueue $pq + * @param array $messages Array of message views + * @return void + */ + private function consumeWithAccelerator(ProcessQueue $pq, array $messages): void + { + // Group messages by their group key + $groupedMessages = []; + foreach ($messages as $msg) { + $groupKey = $this->getMessageGroupKey($msg); + $groupedMessages[$groupKey][] = $msg; + } + + $deliveryAttempts = []; + foreach ($groupedMessages as $groupKey => $groupMsgs) { + $deliveryAttempts[$groupKey] = 1; + } + $this->logger->debug("FifoConsumeService accelerator: " . count($groupedMessages) . " groups, " . count($messages) . " total messages"); + + // Process each group's head message concurrently + // In PHP's single-threaded model, we iterate through groups and consume + // the head of each group one by one, rather than all from one group first. + // This gives interleaved FIFO consumption across groups. + $groupKeys = array_keys($groupedMessages); + $hasMore = true; + + while ($hasMore && !$pq->isDropped()) { + $hasMore = false; + foreach ($groupKeys as $groupKey) { + if (empty($groupedMessages[$groupKey])) { + continue; + } + + $hasMore = true; + $messageView = reset($groupedMessages[$groupKey]); + + if ($pq->isDropped()) { + return; + } + + // Consume this message and remove it from the group + array_shift($groupedMessages[$groupKey]); + + if ($messageView->isCorrupted()) { + $messageId = $messageView->getMessageId() ?: 'unknown'; + $this->logger->error("FifoConsumeService accelerator: Message $messageId is corrupted"); + $pq->discardFifoMessage($messageView); + continue; + } + + $result = $this->consumeMessage($messageView); + + if ($result === ConsumeResult::SUCCESS) { + $this->ackMessage($messageView); + $pq->evictMessage($messageView); + } elseif ($result instanceof ConsumeResultSuspend) { + $this->handleSuspend($pq, $messageView, $result); + } else { + $attempt = $deliveryAttempts[$groupKey]; + // On failure, retry with delay, then continue with other groups + $this->handleFailure($pq, $messageView, $attempt); + } + } + } + } + + /** + * Handle consumption failure with retry. + * + * @param ProcessQueue $pq + * @param object $messageView + * @param int $deliveryAttempt Current delivery attempt number + * @return void + */ + private function handleFailure(ProcessQueue $pq, object $messageView, int $deliveryAttempt): void + { + if ($pq->isDropped()) { + return; + } + + if ($deliveryAttempt < $this->maxAttempts) { + $this->logger->warning("FifoConsumeService message consume failed, attempt {$deliveryAttempt}/{$this->maxAttempts}"); + $this->nackMessage($messageView, $deliveryAttempt); + $pq->evictMessage($messageView); + $retryPolicy = $this->consumer->getRetryPolicy(); + if ($retryPolicy instanceof RetryPolicyInterface) { + $delayMs = $retryPolicy->getNextAttemptDelayMs($deliveryAttempt); + } else { + $delayMs = min(pow(2, $deliveryAttempt - 1) * 10000, 30000); + } + SwooleCompat::sleepBlocking($delayMs * 1000, fn($msg) => $this->logger->debug("FifoConsumeService retry : {$msg}")); + $this->consumeFifoIteratively($pq, $messageView, $deliveryAttempt + 1); + } else { + $this->logger->error("FifoConsumeService max attempts reached for message, forwarding to DLQ"); + $this->forwardToDeadLetterQueue($messageView, $deliveryAttempt); + $pq->evictMessage($messageView); + } + } + + /** + * Execute NACK interceptor for change-invisible-duration hook. + * + * @param bool $success Whether the NACK was successful + * @param string $messageId Message ID being NACKed + * @param string $topic Topic name + * @param int $deliveryAttempt Current delivery attempt count + * @param int $delaySeconds Delay in seconds before next delivery + * @return void + */ + protected function executeNackInterceptor(bool $success, string $messageId, string $topic, int $deliveryAttempt, int $delaySeconds): void + { + $this->consumer->executeInterceptors(MessageHookPoints::CHANGE_INVISIBLE_DURATION, [ + 'success' => $success, + 'messageId' => $messageId, + 'topic' => $topic, + 'deliveryAttempt' => $deliveryAttempt, + 'delaySeconds' => $delaySeconds + ]); + } + + /** + * Execute DLQ interceptor for forward-to-dead-letter-queue hook. + * + * @param bool $success Whether the DLQ forward was successful + * @param string $messageId Message ID being forwarded + * @param string $topic Topic name + * @param int $deliveryAttempt Current delivery attempt count + * @param int $delaySeconds Delay in seconds before DLQ forwarding + * @return void + */ + protected function executeDLQInterceptor(bool $success, string $messageId, string $topic, ?int $deliveryAttempt = null, ?int $delaySeconds = null): void + { + $this->consumer->executeInterceptors(MessageHookPoints::FORWARD_TO_DLQ, [ + 'success' => $success, + 'messageId' => $messageId, + 'topic' => $topic, + ]); + } + + /** + * Handle suspension result in FIFO consumption. + * + * @param ProcessQueue $pq + * @param object $messageView + * @param ConsumeResultSuspend $suspendResult + * @return void + */ + protected function handleSuspend(ProcessQueue $pq, object $messageView, ConsumeResultSuspend $suspendResult): void + { + if ($pq->isDropped()) { + return; + } + $suspendSec = (int)ceil($suspendResult->getSuspendTimeMs() / 1000); + $this->nackMessage($messageView, 1, $suspendSec); + $pq->evictMessage($messageView); + SwooleCompat::sleepBlocking($suspendResult->getSuspendTimeMs() * 1000, fn($msg) => $this->logger->debug("FifoConsumeService suspend: {$msg}")); + } + + /** + * Consume a message and handle retry/DLQ/SUSPEND logic recursively. + * + * @param ProcessQueue $pq + * @param object $messageView + * @param int $deliveryAttempt Current delivery attempt number + * @return void + */ + private function consumeFifoIteratively(ProcessQueue $pq, object $messageView, int $deliveryAttempt): void + { + if ($pq->isDropped()) { + return; + } + + if ($messageView->isCorrupted()) { + $messageId = $messageView->getMessageId() ?: 'unknown'; + $this->logger->error("FifoConsumeService: discarding Message $messageId is corrupted"); + $pq->discardFifoMessage($messageView); + return; + } + + $result = $this->consumeMessage($messageView); + + if ($result === ConsumeResult::SUCCESS) { + $this->ackMessage($messageView); + $pq->evictMessage($messageView); + } elseif ($result instanceof ConsumeResultSuspend) { + $this->handleSuspend($pq, $messageView, $result); + } else { + $this->handleFailure($pq, $messageView, $deliveryAttempt); + } + } +} diff --git a/php/Consumer.php b/php/Consumer.php deleted file mode 100644 index 85a475b52..000000000 --- a/php/Consumer.php +++ /dev/null @@ -1,48 +0,0 @@ - ChannelCredentials::createInsecure()]); - $request = new ReceiveMessageRequest(); - $mq = new MessageQueue(); - $resource = new Resource(); - $resource->setName('normal_topic'); - $mq->setAcceptMessageTypes([MessageType::NORMAL]); - $mq->setTopic($resource); - $request->setMessageQueue($mq); - $msg = $client->ReceiveMessage($request); - var_dump($msg); - } -} - -$x = new Consumer(); -$x->init(); \ No newline at end of file diff --git a/php/ConsumerInterface.php b/php/ConsumerInterface.php new file mode 100644 index 000000000..9833b5daf --- /dev/null +++ b/php/ConsumerInterface.php @@ -0,0 +1,124 @@ +delays = $delays; + parent::__construct($maxAttempts, 0, 0, 1.0); + } + + /** + * Get the maximum number of attempts. + * + * @return int + */ + public function getMaxAttempts(): int + { + return $this->maxAttempts; + } + + /** + * Get the delay for the given attempt from the configured sequence. + * Cycles through the delays if the attempt exceeds the list length. + * + * @param int $attempt Current attempt number (1-based) + * @return int Delay in milliseconds + */ + public function getNextDelayMs(int $attempt): int + { + if ($attempt >= $this->maxAttempts) { + return 0; + } + + $count = count($this->delays); + if ($count === 0) { + return 0; + } + + $index = min($attempt - 1, $count - 1); + return $this->delays[$index]; + } + + /** + * Get the configured delay durations in milliseconds. + * + * @return array + */ + public function getDurations(): array + { + return $this->delays; + } + + /** + * Create from protobuf RetryPolicy. + * + * @param \Apache\Rocketmq\V2\RetryPolicy $protobuf + * @return self + * @throws \InvalidArgumentException if not a customized backoff + */ + public static function fromProtobuf(V2\RetryPolicy $protobuf): self + { + if (!$protobuf->hasCustomizedBackoff()) { + throw new \InvalidArgumentException( + "RetryPolicy is not a customized backoff" + ); + } + + $customizedBackoff = $protobuf->getCustomizedBackoff(); + $delays = []; + foreach ($customizedBackoff->getNext() as $duration) { + $delays[] = (int)($duration->getSeconds() * 1000 + intdiv($duration->getNanos(), 1000000)); + } + + return new self($protobuf->getMaxAttempts(), $delays); + } + + /** + * Convert to protobuf RetryPolicy. + * + * @return \Apache\Rocketmq\V2\RetryPolicy + */ + public function toProtobuf(): V2\RetryPolicy + { + $customizedBackoff = new \Apache\Rocketmq\V2\CustomizedBackoff(); + $nextDurations = []; + foreach ($this->delays as $delayMs) { + $duration = new \Google\Protobuf\Duration(); + $duration->setSeconds(intdiv($delayMs, 1000)); + $duration->setNanos(($delayMs % 1000) * 1000000); + $nextDurations[] = $duration; + } + $customizedBackoff->setNext($nextDurations); + + $retryPolicy = new \Apache\Rocketmq\V2\RetryPolicy(); + $retryPolicy->setMaxAttempts($this->maxAttempts); + $retryPolicy->setCustomizedBackoff($customizedBackoff); + + return $retryPolicy; + } + + /** + * Inherit durations from server-side retry policy but keep own maxAttempts. + * + * @param \Apache\Rocketmq\V2\RetryPolicy $serverPolicy + * @return self New policy with inherited durations + * @throws \InvalidArgumentException if server policy is not customized backoff + */ + public function inheritBackoff(V2\RetryPolicy $serverPolicy): self + { + if (!$serverPolicy->hasCustomizedBackoff()) { + throw new \InvalidArgumentException( + "Cannot inherit backoff: server policy is not a customized backoff" + ); + } + + $serverBackoff = $serverPolicy->getCustomizedBackoff(); + $inheritedDelays = []; + foreach ($serverBackoff->getNext() as $duration) { + $inheritedDelays[] = (int)($duration->getSeconds() * 1000 + intdiv($duration->getNanos(), 1000000)); + } + + return new self($this->maxAttempts, $inheritedDelays); + } +} diff --git a/php/ExponentialBackoffRetryPolicy.php b/php/ExponentialBackoffRetryPolicy.php new file mode 100644 index 000000000..166fd833d --- /dev/null +++ b/php/ExponentialBackoffRetryPolicy.php @@ -0,0 +1,99 @@ += 1) + * @param int $baseDelayMs Base delay in milliseconds + * @param int $maxDelayMs Maximum delay cap in milliseconds + * @param float $multiplier Multiplier for each subsequent attempt + * @throws \InvalidArgumentException if maxAttempts < 1 + */ + public function __construct( + protected readonly int $maxAttempts = 3, + private int $baseDelayMs = 1000, + private int $maxDelayMs = 30000, + private float $multiplier = 2.0, + ) { + if ($this->maxAttempts < 1) { + throw new \InvalidArgumentException("maxAttempts must be >= 1"); + } + $this->baseDelayMs = max(0, $baseDelayMs); + $this->maxDelayMs = max($this->baseDelayMs, $maxDelayMs); + $this->multiplier = max(1.0, $multiplier); + } + + /** + * Get the maximum number of attempts. + * + * @return int + */ + public function getMaxAttempts(): int + { + return $this->maxAttempts; + } + + /** + * Compute the next delay in milliseconds for the given attempt. + * + * @param int $attempt Current attempt number (1-based) + * @return int Delay in milliseconds + */ + public function getNextDelayMs(int $attempt): int + { + if ($attempt >= $this->maxAttempts) { + return 0; + } + + $delay = $this->baseDelayMs * pow($this->multiplier, $attempt - 1); + return min((int)$delay, $this->maxDelayMs); + } + + /** + * Compute the next delay with jitter to avoid thundering herd. + * Jitter adds random factor: delay * (0.5 + rand(0,1) * 0.5) + * + * @param int $attempt Current attempt number (1-based) + * @return int Delay in milliseconds with jitter + */ + public function getNextDelayWithJitterMs(int $attempt): int + { + $delay = $this->getNextDelayMs($attempt); + if ($delay <= 0) { + return 0; + } + + $jitter = 0.5 + (mt_rand() / mt_getrandmax()) * 0.5; + return (int)($delay * $jitter); + } +} diff --git a/php/GrpcTimeout.php b/php/GrpcTimeout.php new file mode 100644 index 000000000..0bc35bb9d --- /dev/null +++ b/php/GrpcTimeout.php @@ -0,0 +1,94 @@ + 30_000_000, // 30s + self::SEND_MESSAGE => 10_000_000, // 10s + self::RECEIVE_MESSAGE => 60_000_000, // 60s (long polling) + self::ACK_MESSAGE => 5_000_000, // 5s + self::HEARTBEAT => 5_000_000, // 5s + self::QUERY_ROUTE => 10_000_000, // 10s + self::QUERY_ASSIGNMENT => 10_000_000, // 10s + self::END_TRANSACTION => 10_000_000, // 10s + self::CHANGE_INVISIBLE => 5_000_000, // 5s + self::FORWARD_DLQ => 5_000_000, // 5s + self::RECALL_MESSAGE => 10_000_000, // 10s + self::SYNC_LITE => 10_000_000, // 10s + }; + } + + /** + * Get the timeout value in milliseconds. + * + * @return int Timeout in milliseconds + */ + public function toMilliseconds(): int + { + return intdiv($this->toMicroseconds(), 1000); + } + + /** + * Get the timeout value in seconds. + * + * @return float Timeout in seconds + */ + public function toSeconds(): float + { + return $this->toMicroseconds() / 1_000_000; + } + + /** + * Get the timeout as a gRPC metadata value string (microseconds with 'u' suffix). + * + * @return string e.g. "10000000u" + */ + public function toGrpcMetadata(): string + { + return $this->toMicroseconds() . 'u'; + } +} diff --git a/php/HeartbeatManager.php b/php/HeartbeatManager.php new file mode 100644 index 000000000..ff670dcf7 --- /dev/null +++ b/php/HeartbeatManager.php @@ -0,0 +1,223 @@ +logger = Logger::getInstance('HeartbeatManager'); + } + + public function isInProgress(): bool + { + return $this->inProgress; + } + + public function getLastHeartbeatTime(): int + { + return $this->lastHeartbeatTime; + } + + /** + * Start periodic heartbeat to all route endpoints. + */ + public function start(): void + { + $this->doHeartbeat(); + $this->lastHeartbeatTime = time(); + + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + $self = $this; + $this->timerId = SwooleCompat::tick(10000, function () use ($self) { + $self->onTick(); + }); + if ($this->timerId > 0) { + $this->logger->debug("Started heartbeat with Swoole timer, timerId={$this->timerId}"); + return; + } + } + if (function_exists('pcntl_signal') && function_exists('pcntl_alarm')) { + $self = $this; + pcntl_signal(SIGALRM, function () use ($self) { + $self->onTick(); + pcntl_alarm(10); + }); + pcntl_alarm(10); + $this->logger->debug("Started heartbeat with PCNTL alarm"); + } + } + + /** + * Stop the periodic heartbeat and cancel pending alarm signals. + */ + public function stop(): void + { + if ($this->timerId > 0) { + SwooleCompat::clearTimer($this->timerId); + $this->timerId = -1; + $this->logger->debug("Swoole heartbeat timer cleared"); + } + if (function_exists('pcntl_alarm')) { + pcntl_alarm(0); + } + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGALRM, SIG_DFL); + } + + $waitCount = 0; + while ($this->inProgress && $waitCount < 10) { + SwooleCompat::sleep(10000); + $waitCount++; + } + + if ($this->inProgress) { + $this->logger->warning("Heartbeat still in progress after waiting, forcing shutdown"); + } else { + $this->logger->debug("Heartbeat timer stopped cleanly"); + } + } + + /** + * Heartbeat tick handler, invoked by alarm signal or Swoole timer. + */ + public function onTick(): void + { + $now = time(); + if ($now - $this->lastHeartbeatTime >= 10) { + if ($this->inProgress) { + $this->logger->debug("Heartbeat already in progress, skipping this tick"); + return; + } + + $this->inProgress = true; + try { + $this->doHeartbeat(); + $this->lastHeartbeatTime = time(); + + static $lastRouteRefresh = 0; + if (time() - $lastRouteRefresh >= 30) { + $this->routeManager->refreshRouteCache(); + $lastRouteRefresh = time(); + } + } catch (\Throwable $e) { + $this->logger->warning("Heartbeat tick failed: " . $e->getMessage()); + } finally { + $this->inProgress = false; + } + } + } + + /** + * Send a heartbeat to all broker endpoints in the route cache. + */ + public function doHeartbeat(): void + { + $routeCache = $this->routeManager->getRouteCache(); + if (empty($routeCache)) { + return; + } + + $brokerEndpoints = $this->routeManager->getTotalRouteEndpoints(); + if (empty($brokerEndpoints)) { + return; + } + + $request = new HeartbeatRequest(); + $request->setClientType(ClientType::PRODUCER); + + foreach ($brokerEndpoints as $endpoints) { + $addresses = $endpoints->getAddresses(); + if (empty($addresses) || $addresses[0] === null) { + continue; + } + $address = $addresses[0]; + $brokerKey = $address->getHost() . ':' . $address->getPort(); + try { + $brokerClient = RpcClientManager::getInstance()->getClient($brokerKey, [ + 'tlsCredentials' => $this->tlsCredentials, + 'sslEnabled' => $this->sslEnabled, + ]); + + $heartbeatTimeoutMs = (int)($this->traitProvider->getOperationTimeout('HEARTBEAT') / 1000); + $metadata = $this->traitProvider->buildMetadata($heartbeatTimeoutMs); + $callOptions = ['timeout' => $this->traitProvider->getOperationTimeout('HEARTBEAT')]; + + list($response, $status) = $brokerClient->Heartbeat($request, $metadata, $callOptions)->wait(); + if ($status->code === 0) { + $this->logger->debug("Heartbeat to broker {$brokerKey} successful"); + $this->routeManager->clearIsolatedEndpoints(); + } else { + $this->logger->warning("Heartbeat to broker {$brokerKey} failed:" . $status->details); + } + } catch (\Exception $e) { + $this->logger->warning("Heartbeat to broker {$brokerKey} failed:" . $e->getMessage()); + } + } + } + + /** + * Notify the server that this client is terminating. + */ + public function notifyClientTermination(): void + { + $routeCache = $this->routeManager->getRouteCache(); + if (empty($routeCache)) { + return; + } + + $request = new NotifyClientTerminationRequest(); + + $timeoutMs = (int)($this->traitProvider->getOperationTimeout('HEARTBEAT') / 1000); + $metadata = $this->traitProvider->buildMetadata($timeoutMs); + $callOptions = ['timeout' => $this->traitProvider->getOperationTimeout('HEARTBEAT')]; + + try { + list($response, $status) = $this->client->NotifyClientTermination($request, $metadata, $callOptions)->wait(); + if ($status->code === 0) { + $this->logger->debug("NotifyClientTermination sent successfully"); + } else { + $this->logger->warning("NotifyClientTermination failed: " . $status->details); + } + } catch (\Exception $e) { + $this->logger->warning("NotifyClientTermination exception: " . $e->getMessage()); + } + } +} diff --git a/php/IntMath.php b/php/IntMath.php new file mode 100644 index 000000000..ed5fdb009 --- /dev/null +++ b/php/IntMath.php @@ -0,0 +1,35 @@ +getSystemProperties(); + if ($sysProps !== null && $sysProps->hasLiteTopic()) { + return $sysProps->getLiteTopic(); + } + return 'default'; + } + + /** + * Suspend and nack all cached messages matching the same liteTopic, then sleep. + * + * @param ProcessQueue $pq The process queue holding cached messages + * @param object $messageView The message that triggered the suspend + * @param ConsumeResultSuspend $suspendResult The suspend result with duration + * @return void + */ + protected function handleSuspend(ProcessQueue $pq, object $messageView, ConsumeResultSuspend $suspendResult): void + { + if ($pq->isDropped()) { + return; + } + $targetLiteTopic = null; + $sysProps = $messageView->getSystemProperties(); + if ($sysProps !== null && $sysProps->hasLiteTopic()) { + $targetLiteTopic = $sysProps->getLiteTopic(); + } + $suspendSec = (int)ceil($suspendResult->getSuspendTimeMs() / 1000); + $cachedMessages = $pq->getCachedMessages(); + $suspendedCount = 0; + foreach ($cachedMessages as $msg) { + if ($targetLiteTopic !== null) { + $msgLiteTopic = null; + $msgSysProps = $msg->getSystemProperties(); + if ($msgSysProps !== null && $msgSysProps->hasLiteTopic()) { + $msgLiteTopic = $msgSysProps->getLiteTopic(); + } + if ($msgLiteTopic === $targetLiteTopic) { + $this->nackMessage($msg, 1, $suspendSec); + $pq->evictMessage($msg); + $suspendedCount++; + } + } + } + $this->logger->debug("LiteFifoConsumeService batch-suspended {$suspendedCount} messages with same liteTopic, suspendSec={$suspendSec}"); + SwooleCompat::sleepBlocking($suspendResult->getSuspendTimeMs() * 1000, fn($msg) => $this->logger->debug("LiteFifoConsumeService suspend : {$msg}")); + } +} diff --git a/php/LitePushConsumer.php b/php/LitePushConsumer.php new file mode 100644 index 000000000..be887af1b --- /dev/null +++ b/php/LitePushConsumer.php @@ -0,0 +1,335 @@ +subscribeLite('lite-topic-1', $callback); + * $consumer->subscribeLite('lite-topic-2', $callback); + * $consumer->start(); + */ +class LitePushConsumer extends PushConsumer +{ + private readonly string $parentTopic; + private array $liteTopics = []; + private readonly int $liteSubscriptionQuota; + private readonly int $maxLiteTopicSize; + private int $syncLiteSubscriptionInterval = 30; + /** @var callable|null per-lite-topic callback */ + private ?\Closure $liteMessageListener = null; + private int $lastSyncTime = 0; + private ?ProcessQueue $virtualProcessQueue = null; // ProcessQueue|null + + /** + * Constructor. + * + * @param string $endpoints gRPC server endpoint + * @param string $consumerGroup Consumer group name + * @param string $parentTopic Parent (bound) topic + * @param array $options Configuration options + * - clientId: string, custom client identifier (default: 'php-push-consumer-{pid}-{time}') + * - messageListener: callable|null, message consumption callback + * - maxCacheMessageCount: int, max cached messages in memory (default: 4096) + * - maxCacheMessageSizeInBytes: int, max cached message total size (default: 67108864, 64MB) + * - awaitDuration: int, long polling timeout in seconds (default: 5) + * - scanIntervalSeconds: int, assignment scan interval in seconds (default: 5) + * - receiveBatchSize: int, max messages per receive batch (default: 32) + * - enableFifoConsumeAccelerator: bool, enable FIFO consume accelerator (default: true for Lite) + * - credentials: SessionCredentials|null, AK/SK authentication credentials + * - namespace: string, resource namespace prefix (default: '') + * - tlsCredentials: TlsCredentials|null, TLS/SSL configuration + * - sslEnabled: bool, enable SSL for gRPC channel (default: true) + * - liteSubscriptionQuota: int, max number of lite topic subscriptions (default: 0 = unlimited) + * - maxLiteTopicSize: int, max length of lite topic name (default: 64) + */ + public function __construct(string $endpoints, string $consumerGroup, $parentTopic, array $options = []) + { + if (empty(trim($parentTopic))) { + throw new \InvalidArgumentException("LitePushConsumer parentTopic cannot be empty"); + } + $this->parentTopic = $parentTopic; + $listener = $options['messageListener'] ?? null; + $this->liteMessageListener = $listener !== null + ? ($listener instanceof \Closure ? $listener : \Closure::fromCallable($listener)) + : null; + + $liteOptions = array_merge($options, [ + 'subscriptionExpressions' => [$parentTopic => '*'], + 'fifo' => true, + 'isLiteConsumer' => true, + 'enableFifoConsumeAccelerator' => $options['enableFifoConsumeAccelerator'] ?? true, + 'messageListener' => $this->liteMessageListener, + ]); + + parent::__construct($endpoints, $consumerGroup, $liteOptions); + + $this->liteSubscriptionQuota = $options['liteSubscriptionQuota'] ?? 0; + $this->maxLiteTopicSize = $options['maxLiteTopicSize'] ?? 64; + } + + /** + * Subscribe to a lite topic. + * + * @param string $liteTopic Lite topic name + * @param callable|null $listener Optional per-lite-topic callback + * @return $this + */ + public function subscribeLite(string $liteTopic, ?callable $listener = null): self + { + $this->checkNotRunning(); + + if (strlen($liteTopic) > $this->maxLiteTopicSize) { + throw new \RuntimeException("Lite topic name exceeds max length of {$this->maxLiteTopicSize}"); + } + + if ($this->liteSubscriptionQuota > 0 && count($this->liteTopics) >= $this->liteSubscriptionQuota) { + throw new \RuntimeException("Lite subscription quota exceeded: {$this->liteSubscriptionQuota}"); + } + + $this->liteTopics[$liteTopic] = $listener; + + return $this; + } + + /** + * Get the client type for this consumer. + * + * @return int ClientType::LITE_PUSH_CONSUMER + */ + protected function getClientType(): int + { + return ClientType::LITE_PUSH_CONSUMER; + } + + /** + * Unsubscribe from a lite topic. + * + * @param string $liteTopic Lite topic name to remove + * @return $this + */ + public function unsubscribeLite(string $liteTopic): self + { + $this->checkNotRunning(); + unset($this->liteTopics[$liteTopic]); + return $this; + } + + /** + * Get subscribed lite topics. + * + * @return array + */ + public function getLiteTopics(): array + { + return array_keys($this->liteTopics); + } + + /** + * Set the global lite message listener (used when no per-lite-topic listener is set). + * + * @param callable $listener Callback invoked for messages with no per-lite-topic listener + * @return $this + */ + public function setLiteMessageListener(callable $listener): self + { + $this->liteMessageListener = $listener instanceof \Closure + ? $listener + : \Closure::fromCallable($listener); + return $this; + } + + /** + * Start the LitePushConsumer. + * + * Overrides parent start() to sync lite subscriptions, register handlers, and use lite-aware consume service. + */ + public function start(): void + { + if ($this->isRunning()) { + return; + } + + if (empty($this->liteTopics)) { + throw new \RuntimeException("LitePushConsumer has no lite topics subscribed"); + } + + if ($this->liteMessageListener === null) { + throw new \RuntimeException("LitePushConsumer has no lite message listener"); + } + + $this->logger->info("LitePushConsumer starting, clientId={$this->getClientId()}, parentTopic={$this->parentTopic}"); + parent::start(); + } + + /** + * Setup before the main scan loop: sync lite subscriptions and wait for assignments. + */ + protected function onStartBeforeLoop(): void + { + $self = $this; + $this->telemetrySession->setOnNotifyUnsubscribeLite(function ($notifyCmd) use ($self) { + $liteTopic = $notifyCmd->getLiteTopic(); + $self->logger->info("Received NotifyUnsubscribeLite for liteTopic={$liteTopic}"); + $self->handleUnsubscribeLite($liteTopic); + }); + $this->syncLiteSubscriptions(); + $this->lastSyncTime = time(); + $pollInterval = 500000; + $maxAttempts = 10; + for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { + try { + $assignments = $this->queryLiteAssignment(); + $assignmentList = $assignments ? ProtobufUtil::repeatedFieldToArray($assignments->getAssignments()) : []; + if (!empty($assignmentList)) { + $this->logger->info("Lite subscription active after " . ($attempt + 500) . "ms" . count($assignmentList) . " assignments"); + return; + } + } catch (\Exception $e) { + $this->logger->error("Error querying lite subscription: " . $e->getMessage()); + } + $this->logger->debug("Waiting doe lite subscription to take effect, attempt {$attempt}/{$maxAttempts}"); + SwooleCompat::sleep($pollInterval); + } + $this->logger->error("Lite subscription time out waiting assignments, will scan during normal cycle"); + } + + /** + * Handle server-initiated lite topic unsubscription. + * + * @param string $liteTopic Lite topic name to remove from subscriptions + * @return void + */ + public function handleUnsubscribeLite(string $liteTopic): void + { + if (array_key_exists($liteTopic, $this->liteTopics)) { + unset($this->liteTopics[$liteTopic]); + $this->logger->info("Unsubscribed from lite topic: {$liteTopic}"); + } + } + + /** + * Hook called after each scan cycle to perform periodic lite sync. + */ + protected function onScanCycleComplete(): void + { + $now = time(); + if (!empty($this->liteTopics) && ($now - $this->lastSyncTime) >= $this->syncLiteSubscriptionInterval) { + $this->syncLiteSubscriptions(); + $this->lastSyncTime = $now; + } + } + + /** + * Sync lite subscriptions to server via SyncLiteSubscription gRPC. + */ + public function syncLiteSubscriptions(): void + { + if (empty($this->liteTopics)) { + return; + } + + $topicResource = new Resource(); + $topicResource->setName($this->parentTopic); + + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + + $request = new SyncLiteSubscriptionRequest(); + $request->setAction(LiteSubscriptionAction::COMPLETE_ADD); + $request->setTopic($topicResource); + $request->setGroup($groupResource); + $request->setLiteTopicSet(array_keys($this->liteTopics)); + + $metadata = $this->buildMetadata(ClientConstants::GRPC_SYNC_LITE_MESSAGE_TIMEOUT / 1000); + + try { + list($response, $status) = $this->getClient()->SyncLiteSubscription($request, $metadata, $this->getCallOptions())->wait(); + if ($status->code !== 0) { + throw new \RuntimeException("SyncLiteSubscription failed: " . $status->details); + } + $this->logger->info("SyncLiteSubscription success for " . count($this->liteTopics) . " lite topics"); + } catch (\RuntimeException $e) { + throw $e; + } catch (\Exception $e) { + throw new \RuntimeException("SyncLiteSubscription exception: " . $e->getMessage(), 0, $e); + } + } + + /** + * Get the lite message listener for a specific lite topic. + * + * @param string $liteTopic + * @return callable|null + */ + public function getLiteMessageListener(string $liteTopic): ?callable + { + if (isset($this->liteTopics[$liteTopic])) { + $listener = $this->liteTopics[$liteTopic]; + if (is_callable($listener)) { + return $listener; + } + } + return $this->liteMessageListener; + } + + /** + * Check if consumer is in FIFO mode (lite consumers always use FIFO consume service). + * + * @return bool + */ + public function isLiteConsumer(): bool + { + return true; + } + + /** + * Query the server for lite topic assignment information. + * + * @return object|null Assignment response or null on failure + */ + private function queryLiteAssignment(): ?object + { + $topicResource = new \Apache\Rocketmq\V2\Resource(); + $topicResource->setName($this->parentTopic); + $groupResource = new \Apache\Rocketmq\V2\Resource(); + $groupResource->setName($this->consumerGroup); + $request = new \Apache\Rocketmq\V2\QueryAssignmentRequest(); + $request->setTopic($topicResource); + $request->setGroup($groupResource); + $request->setEndpoints($this->parseEndpoints($this->endpoints)); + $metadata = $this->buildMetadata(ClientConstants::GRPC_SYNC_LITE_MESSAGE_TIMEOUT / 1000); + list($response, $status) = $this->getClient()->QueryAssignment($request, $metadata, $this->getCallOptions())->wait(); + if ($status->code !== 0) { + return null; + } + return $response; + } +} diff --git a/php/LitePushConsumerBuilder.php b/php/LitePushConsumerBuilder.php new file mode 100644 index 000000000..9b9522587 --- /dev/null +++ b/php/LitePushConsumerBuilder.php @@ -0,0 +1,428 @@ +setEndpoints('127.0.0.1:8081') + * ->setConsumerGroup('lite-group') + * ->bindTopic('ParentTopic') + * ->subscriptionLite('lite-topic-a') + * ->subscriptionLite('lite-topic-b') + * ->setMessageListener(function (MessageView $mv): int { + * echo $mv->getBody(); + * return ConsumeResult::SUCCESS; + * }) + * ->build(); + * ``` + * + * Usage example — bounded run with startFor(): + * ```php + * (new LitePushConsumerBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setConsumerGroup('lite-group') + * ->bindTopic('ParentTopic') + * ->subscriptionLite('lite-topic-a') + * ->setMessageListener(fn(MessageView $mv) => ConsumeResult::SUCCESS) + * ->startFor(120); // run for 120 seconds, then shutdown + * ``` + * + * Usage example — with ClientConfiguration and async start: + * ```php + * $config = (new ClientConfigurationBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setSessionCredentialsProvider(new SessionCredentials('ak', 'sk')) + * ->build(); + * + * $consumer = (new LitePushConsumerBuilder()) + * ->setClientConfiguration($config) + * ->setConsumerGroup('lite-group') + * ->bindTopic('ParentTopic') + * ->subscriptionLite('lite-topic-a') + * ->setMessageListener(fn(MessageView $mv) => ConsumeResult::SUCCESS) + * ->buildAsync(); + * ``` + * + * @see LitePushConsumer + * @see ClientConfiguration + * @see ConsumeResult + */ +class LitePushConsumerBuilder +{ + private string $endpoints = ''; + private string $consumerGroup = ''; + private string $parentTopic = ''; + private ?SessionCredentials $credentials = null; + /** @var callable|null */ + private $messageListener = null; + private int $maxCacheMessageCount = 4096; + private int $maxCacheMessageSizeInBytes = 67108864; + private int $consumptionThreadCount = 1; + private bool $enableFifoConsumeAccelerator = true; + private string $namespace = ''; + private array $liteTopics = []; + private ?TlsCredentials $tlsCredentials = null; + + /** + * Bulk-import settings from a {@see ClientConfiguration} instance. + * + * Copies endpoints, credentials, namespace, and tlsCredentials from the + * config object. Individual setter calls made **after** this method will + * override the imported values. + * + * @param ClientConfiguration $config Pre-built client configuration + * @return $this For method chaining + */ + public function setClientConfiguration(ClientConfiguration $config): self + { + $this->endpoints = $config->getEndpoints(); + $this->credentials = $config->getSessionCredentialsProvider(); + $this->namespace = $config->getNamespace(); + if ($config->getTlsCredentials() !== null) { + $this->tlsCredentials = $config->getTlsCredentials(); + } + return $this; + } + + /** + * Subscribe to a lite (child) topic within the parent topic. + * + * Lite topics are lightweight sub-topics within a parent topic. Messages + * sent to a lite topic are stored in the parent topic's queue but tagged + * with the lite topic name, enabling fine-grained subscription filtering. + * Multiple lite topics can be subscribed; each call adds to the list. + * + * At least one lite topic must be subscribed; buildWithoutStart() throws + * if the list is empty. + * + * @param string $liteTopic Lite topic name to subscribe to + * @return $this For method chaining + * @default [] (no lite topics) + * @see bindTopic() for setting the parent topic + */ + public function subscriptionLite(string $liteTopic): self + { + $this->liteTopics[$liteTopic] = null; + return $this; + } + + /** + * Set the consumer group name. + * + * The consumer group identifies this consumer on the server side. All + * consumers sharing the same group name will load-balance messages; + * consumers in different groups each receive a full copy of every message. + * This is a required setting. + * + * @param string $consumerGroup Consumer group name (must match server-side group config) + * @return $this For method chaining + * @default '' (empty — buildWithoutStart() will throw) + */ + public function setConsumerGroup(string $consumerGroup): self + { + $this->consumerGroup = $consumerGroup; + return $this; + } + + /** + * Bind a single parent topic for lite messaging. + * + * The parent topic is the physical storage topic on the broker. All lite + * (child) topics subscribed via subscriptionLite() are logically partitioned + * within this parent topic. Only one parent topic can be bound; calling + * bindTopic() again overwrites the previous value. + * + * This is a required setting — buildWithoutStart() throws if not set. + * + * @param string $parentTopic Parent topic name on the broker + * @return $this For method chaining + * @default '' (empty — buildWithoutStart() will throw) + */ + public function bindTopic(string $parentTopic): self + { + $this->parentTopic = $parentTopic; + return $this; + } + + /** + * Set the message listener callback invoked for each received message. + * + * The listener receives a {@see MessageView} and must return a ConsumeResult: + * - ConsumeResult::SUCCESS — message processed, will be acknowledged + * - ConsumeResult::FAILURE — processing failed, message will be retried + * + * This listener serves as the default handler for all lite topics. + * Per-lite-topic listeners can be registered via subscribeLite() on the + * consumer after buildWithoutStart(). This is a required setting. + * + * @param callable $listener Signature: function(MessageView $mv): int + * @return $this For method chaining + * @default null (buildWithoutStart() will throw) + */ + public function setMessageListener(callable $listener): self + { + $this->messageListener = $listener; + return $this; + } + + /** + * Set max number of messages buffered in memory awaiting listener dispatch. + * + * Controls the prefetch buffer size. A larger value increases throughput + * at the cost of higher memory usage and potential message redelivery on + * consumer crash. + * + * @param int $count Max cached message count + * @return $this For method chaining + * @default 4096 + * @valid-range Must be > 0 + * @throws \InvalidArgumentException if $count <= 0 + */ + public function setMaxCacheMessageCount(int $count): self + { + if ($count <= 0) { + throw new \InvalidArgumentException("maxCacheMessageCount must be > 0"); + } + $this->maxCacheMessageCount = $count; + return $this; + } + + /** + * Set max total size of messages buffered in memory (in bytes). + * + * Secondary flow-control limit alongside maxCacheMessageCount. When the + * cumulative body size of cached messages exceeds this threshold, the + * consumer pauses prefetching until messages are consumed. + * + * @param int $bytes Max cached message size in bytes + * @return $this For method chaining + * @default 67108864 (64 MB) + * @valid-range Must be > 0 + * @throws \InvalidArgumentException if $bytes <= 0 + */ + public function setMaxCacheMessageSizeInBytes(int $bytes): self + { + if ($bytes <= 0) { + throw new \InvalidArgumentException("maxCacheMessageSizeInBytes must be > 0"); + } + $this->maxCacheMessageSizeInBytes = $bytes; + return $this; + } + + /** + * Set consumption thread count (stored for API parity, no-op in PHP). + * + * In the Java/Go SDK this controls the thread pool size for concurrent + * message dispatch. PHP uses an event-loop model, so this value is stored + * but does not create OS threads. + * + * @param int $count Thread count hint + * @return $this For method chaining + * @default 1 + */ + public function setConsumptionThreadCount(int $count): self + { + $this->consumptionThreadCount = $count; + return $this; + } + + /** + * Enable FIFO consume accelerator for parallel processing by messageGroup. + * + * When enabled (default for LitePushConsumer), messages from different + * messageGroups are dispatched concurrently while maintaining order within + * each group. This is the default for LitePushConsumer because lite topics + * typically involve many concurrent message groups. + * + * @param bool $enable true to enable parallel group processing + * @return $this For method chaining + * @default true (enabled by default for LitePushConsumer) + */ + public function setEnableFifoConsumeAccelerator(bool $enable): self + { + $this->enableFifoConsumeAccelerator = $enable; + return $this; + } + + /** + * Set the resource namespace prefix. + * + * @param string $namespace Namespace string (empty string = no namespace) + * @return $this For method chaining + * @default '' (no namespace) + */ + public function setNamespace(string $namespace): self + { + $this->namespace = $namespace; + return $this; + } + + /** + * Set custom TLS credentials for the gRPC connection. + * + * @param TlsCredentials $tlsCredentials TLS certificate configuration + * @return $this For method chaining + * @default null (use system trust store) + */ + public function setTlsCredentials(TlsCredentials $tlsCredentials): self + { + $this->tlsCredentials = $tlsCredentials; + return $this; + } + + /** + * Build the LitePushConsumer without starting it. + * + * Validates all required fields and constructs a LitePushConsumer instance. + * All lite topics registered via subscriptionLite() are bound to the consumer. + * The returned consumer is NOT running — call start(), startAsync(), or + * startWithTimeout() separately. + * + * Validation rules (all throw \RuntimeException): + * - endpoints must be set (non-empty) + * - consumerGroup must be set (non-empty) + * - messageListener must be set (non-null callable) + * - parentTopic must be bound via bindTopic() + * - at least one lite topic must be subscribed via subscriptionLite() + * + * @return LitePushConsumer A configured but unstarted LitePushConsumer + * @throws \RuntimeException If any required field is missing + */ + public function buildWithoutStart(): LitePushConsumer + { + if ($this->endpoints === '') { + throw new \RuntimeException("LitePushConsumer endpoints must be set"); + } + if ($this->consumerGroup === '') { + throw new \RuntimeException("LitePushConsumer consumerGroup must be set"); + } + if ($this->messageListener === null) { + throw new \RuntimeException("LitePushConsumer messageListener must be set"); + } + if ($this->parentTopic === '') { + throw new \RuntimeException("LitePushConsumer parent topic must be set"); + } + if (empty($this->liteTopics)) { + throw new \RuntimeException("LitePushConsumer must have at least one lite topic"); + } + + $consumer = new LitePushConsumer($this->endpoints, $this->consumerGroup, $this->parentTopic, [ + 'messageListener' => $this->messageListener, + 'maxCacheMessageCount' => $this->maxCacheMessageCount, + 'maxCacheMessageSizeInBytes' => $this->maxCacheMessageSizeInBytes, + 'enableFifoConsumeAccelerator' => $this->enableFifoConsumeAccelerator, + 'namespace' => $this->namespace, + 'credentials' => $this->credentials, + 'tlsCredentials' => $this->tlsCredentials, + ]); + + foreach ($this->liteTopics as $liteTopic =>$listener) { + if ($listener !== null) { + $consumer->subscribeLite($liteTopic, $listener); + } else { + $consumer->subscribeLite($liteTopic); + } + } + return $consumer; + } + + /** + * Build and start the LitePushConsumer synchronously (blocking). + * + * Equivalent to: + * $consumer = $builder->buildWithoutStart(); + * $consumer->start(); // blocks until TelemetrySession is established + * return $consumer; + * + * The returned consumer is actively receiving and dispatching lite messages. + * + * @return LitePushConsumer A started, message-receiving LitePushConsumer + * @throws \RuntimeException If any required field is missing + * @throws \RuntimeException If start() fails (e.g. gRPC connection refused) + */ + public function build(): LitePushConsumer + { + $consumer = $this->buildWithoutStart(); + $consumer->start(); + return $consumer; + } + + /** + * Build, start, run for a fixed duration, then shutdown — all in one call. + * + * Convenient for short-lived consumers, CLI tools, and integration tests. + * Blocks the current thread for the specified duration, then gracefully + * shuts down the consumer. + * + * @param int $seconds Duration in seconds to consume messages + * @return void + * @throws \RuntimeException If any required field is missing + * @throws \RuntimeException If startWithTimeout() fails + */ + public function startFor(int $seconds): void + { + $consumer = $this->buildWithoutStart(); + $consumer->startWithTimeout($seconds); + $consumer->shutdown(); + } + + /** + * Build and start the LitePushConsumer asynchronously (non-blocking). + * + * Starts the consumer in a Swoole coroutine (when available) or returns + * immediately. The optional $onDone callback is invoked once the startup + * sequence completes. + * + * @param callable|null $onDone Optional callback invoked after start completes. + * Signature: function(): void + * @return LitePushConsumer The consumer (may not be fully started yet) + * @throws \RuntimeException If any required field is missing + */ + public function buildAsync(?callable $onDone = null): LitePushConsumer + { + $consumer = $this->buildWithoutStart(); + $consumer->startAsync($onDone); + return $consumer; + } +} diff --git a/php/LocalTransactionExecuter.php b/php/LocalTransactionExecuter.php new file mode 100644 index 000000000..2f4e20643 --- /dev/null +++ b/php/LocalTransactionExecuter.php @@ -0,0 +1,44 @@ +info('Producer started'); + */ +class Logger +{ + const LEVEL_DEBUG = 0; + const LEVEL_INFO = 1; + const LEVEL_WARNING = 2; + const LEVEL_ERROR = 3; + + private static array $instances = []; + private static int $logLevel = self::LEVEL_INFO; + private static ?string $logFile = null; + /** @var resource|false|null */ + private static $handle = null; + + private string $component; + + /** + * Get or create a Logger instance for the given component. + * + * @param string $component Component name (e.g., 'Producer', 'SimpleConsumer') + * @return Logger + */ + public static function getInstance(string $component): self + { + if (!isset(self::$instances[$component])) { + self::$instances[$component] = new self($component); + } + return self::$instances[$component]; + } + + /** + * Set the minimum log level. + * + * @param int $level One of the LEVEL_* constants + * @return void + */ + public static function setLogLevel(int $level): void + { + self::$logLevel = $level; + } + + /** + * Set the log file path. + * + * @param string $path Absolute path to the log file + * @return void + */ + public static function setLogFile(string $path): void + { + if (self::$handle !== null && self::$handle !== false) { + fclose(self::$handle); + } + self::$handle = null; + self::$logFile = $path; + } + + /** + * Detect and correct timezone on first use. + * + * PHP CLI defaults to UTC when date.timezone is not set in php.ini. + * + * @return void + */ + private static function initTimezone(): void + { + $tz = date_default_timezone_get(); + // 'UTC' means system timezone could not be determined + if ($tz === 'UTC') { + // Try common methods to detect system timezone + $systemTz = null; + + // Method 1: /etc/localtime symlink (Linux/macOS) + if ($systemTz === null && is_link('/etc/localtime')) { + $link = readlink('/etc/localtime'); + if ($link !== false) { + // /usr/share/zoneinfo/Asia/Shanghai -> Asia/Shanghai + $parts = explode('/', $link); + if (count($parts) >= 4) { + $candidate = $parts[count($parts) - 2] . '/' . $parts[count($parts) - 1]; + if (in_array($candidate, timezone_identifiers_list(), true)) { + $systemTz = $candidate; + } + } + } + } + + // Method 2: /etc/timezone file (Debian-based Linux) + if ($systemTz === null) { + $tzFile = @file_get_contents('/etc/timezone'); + if ($tzFile !== false) { + $candidate = trim($tzFile); + if (in_array($candidate, timezone_identifiers_list(), true)) { + $systemTz = $candidate; + } + } + } + + // Method 3: timedatectl (systemd-based Linux only) + if ($systemTz === null && \PHP_OS_FAMILY !== 'Windows') { + $output = @shell_exec('timedatectl show --property=Timezone --value 2>/dev/null'); + if ($output !== null) { + $candidate = trim($output); + if ($candidate !== '' && in_array($candidate, timezone_identifiers_list(), true)) { + $systemTz = $candidate; + } + } + } + + if ($systemTz !== null) { + date_default_timezone_set($systemTz); + } + } + } + + /** + * Private constructor to enforce singleton-per-component pattern. + * + * @param string $component Component name for log line prefix + */ + private function __construct(string $component) + { + $this->component = $component; + self::initTimezone(); + } + + /** + * Log a debug message. + * + * @param string $message The log message + * @return void + */ + public function debug(string $message): void + { + $this->log(self::LEVEL_DEBUG, $message); + } + + /** + * Log an info message. + * + * @param string $message The log message + * @return void + */ + public function info(string $message): void + { + $this->log(self::LEVEL_INFO, $message); + } + + /** + * Log a warning message. + * + * @param string $message The log message + * @return void + */ + public function warning(string $message): void + { + $this->log(self::LEVEL_WARNING, $message); + } + + /** + * Log an error message. + * + * @param string $message The log message + * @return void + */ + public function error(string $message): void + { + $this->log(self::LEVEL_ERROR, $message); + } + + /** + * Write a log entry to the log file with timestamp, level, and component prefix. + * + * @param int $level One of the LEVEL_* constants + * @param string $message The log message + * @return void + */ + private function log(int $level, string $message): void + { + if ($level < self::$logLevel) { + return; + } + + $levelNames = [ + self::LEVEL_DEBUG => 'DEBUG', + self::LEVEL_INFO => 'INFO', + self::LEVEL_WARNING => 'WARNING', + self::LEVEL_ERROR => 'ERROR', + ]; + + $ts = microtime(true); + $sec = floor($ts); + $ms = (int)(($ts - $sec) * 1000); + $timeStr = date('Y-m-d H:i:s', $sec) . '.' . str_pad((string)$ms, 3, '0', STR_PAD_LEFT); + + $line = "[$timeStr] [{$levelNames[$level]}] [{$this->component}] $message\n"; + + $handle = $this->getHandle(); + if ($handle !== false) { + flock($handle, LOCK_EX); + fwrite($handle, $line); + fflush($handle); + flock($handle, LOCK_UN); + } + } + + /** + * Get or open the shared log file handle. + * + * @return resource|false The file handle, or false on failure + */ + private function getHandle() + { + if (self::$handle !== null) { + return self::$handle; + } + + if (self::$logFile !== null) { + $logFile = self::$logFile; + } else { + // Cross-platform home directory detection + $home = getenv('HOME'); + if (empty($home)) { + $home = getenv('USERPROFILE'); + } + if (empty($home)) { + $home = sys_get_temp_dir(); + } + $logDir = $home . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . 'rocketmq'; + $logFile = $logDir . DIRECTORY_SEPARATOR . 'rocketmq_client_php.log'; + + // Ensure log directory exists + if (!is_dir($logDir)) { + @mkdir($logDir, 0755, true); + } + } + + $handle = @fopen($logFile, 'a'); + if ($handle === false) { + // Silently disable file logging if file cannot be opened + self::$handle = false; + return false; + } + + self::$handle = $handle; + return self::$handle; + } + + /** + * Close the log file handle. Called on shutdown. + * + * @return void + */ + public static function close(): void + { + if (self::$handle !== null && self::$handle !== false) { + fclose(self::$handle); + self::$handle = null; + } + } + + /** + * Destructor. Does not close the shared handle. + * + * @return void + */ + public function __destruct() + { + // Do not close here - handle is shared across all instances + } +} diff --git a/php/MessageBuilder.php b/php/MessageBuilder.php new file mode 100644 index 000000000..4728a77ca --- /dev/null +++ b/php/MessageBuilder.php @@ -0,0 +1,380 @@ +topic = $topic; + return $this; + } + + /** + * Set the message body (required). + * + * @param string $body Message body content + * @return $this + */ + public function setBody(string $body): self + { + $this->body = $body; + return $this; + } + + /** + * Set the message tag (optional). + * + * @param string|null $tag Tag value + * @return $this + */ + public function setTag(?string $tag): self + { + if ($tag !== null) { + $this->validateTag($tag); + } + $this->tag = $tag; + return $this; + } + + /** + * Set the message keys (optional). + * + * @param array $keys List of key strings + * @return $this + */ + public function setKeys(array $keys): self + { + foreach ($keys as $key) { + $this->validateKey($key); + } + $this->keys = $keys; + return $this; + } + + /** + * Add a single key. + * + * @param string $key + * @return $this + */ + public function addKey(string $key): self + { + $this->validateKey($key); + $this->keys[] = $key; + return $this; + } + + /** + * Set the message group for FIFO messages (optional). + * Mutually exclusive with deliveryTimestamp, liteTopic, and priority. + * + * @param string|null $messageGroup + * @return $this + */ + public function setMessageGroup(?string $messageGroup): self + { + if ($messageGroup !== null) { + $this->validateNoConflict('messageGroup'); + } + $this->messageGroup = $messageGroup; + return $this; + } + + /** + * Set the delivery timestamp for delayed/scheduled messages (optional). + * Mutually exclusive with messageGroup, liteTopic, and priority. + * + * @param int|null $timestampMs Unix timestamp in milliseconds + * @return $this + */ + public function setDeliveryTimestamp(?int $timestampMs): self + { + if ($timestampMs !== null) { + $this->validateNoConflict('deliveryTimestamp'); + $ts = new Timestamp(); + $ts->setSeconds(intdiv($timestampMs, 1000)); + $ts->setNanos(($timestampMs % 1000) * 1000000); + $this->deliveryTimestamp = $ts; + } else { + $this->deliveryTimestamp = null; + } + return $this; + } + + /** + * Set the lite topic sub-classifier (optional). + * Mutually exclusive with messageGroup, deliveryTimestamp, and priority. + * + * @param string|null $liteTopic + * @return $this + */ + public function setLiteTopic(?string $liteTopic): self + { + if ($liteTopic !== null) { + if (trim($liteTopic) === '') { + throw new \InvalidArgumentException("LiteTopic cannot be blank"); + } + $this->validateNoConflict('liteTopic'); + } + $this->liteTopic = $liteTopic; + return $this; + } + + /** + * Set the message priority level (optional). + * Mutually exclusive with messageGroup, deliveryTimestamp, and liteTopic. + * + * @param int|null $priority Priority value (lower = higher priority) + * @return $this + */ + public function setPriority(?int $priority): self + { + if ($priority !== null) { + if ($priority < 1 || $priority > 9) { + throw new \InvalidArgumentException("Priority must be between 1 and 9"); + } + $this->validateNoConflict('priority'); + } + $this->priority = $priority; + return $this; + } + + /** + * Add a user property. + * + * @param string $key Property key + * @param string $value Property value + * @return $this + */ + public function addProperty(string $key, string $value) + { + $this->properties[$key] = $value; + return $this; + } + + /** + * Set the body encoding for compression. + * + * When set to GZIP, ZLIB, ZSTD, or LZ4, the body will be compressed + * during build() and the encoding flag will be set in system properties. + * + * @param string|null $encoding One of Utilities::ENCODING_GZIP_STR, ENCODING_ZLIB_STR, etc. + * @return $this + */ + public function setEncoding(?string $encoding) + { + $this->encoding = $encoding; + return $this; + } + + /** + * Build the Message protobuf object. + * + * @return Message Protobuf message + * @throws \InvalidArgumentException if topic or body is missing + */ + public function build() + { + if ($this->topic === null) { + throw new \InvalidArgumentException("Message topic is required"); + } + if ($this->body === null) { + throw new \InvalidArgumentException("Message body is required"); + } + + $topicResource = new Resource(); + $topicResource->setName($this->topic); + + $message = new Message(); + $message->setTopic($topicResource); + + // Compress body if encoding is set + $body = $this->body; + if ($this->encoding !== null && $this->encoding !== Utilities::ENCODING_IDENTITY_STR) { + $body = Utilities::compressBytes($this->body, $this->encoding); + } + $message->setBody($body); + + // Build system properties only if any are set + if ($this->hasSystemProperties()) { + $sysProps = new SystemProperties(); + + if ($this->tag !== null) { + $sysProps->setTag($this->tag); + } + if (!empty($this->keys)) { + $sysProps->setKeys($this->keys); + } + if ($this->messageGroup !== null) { + $sysProps->setMessageGroup($this->messageGroup); + } + if ($this->deliveryTimestamp !== null) { + $sysProps->setDeliveryTimestamp($this->deliveryTimestamp); + } + if ($this->liteTopic !== null) { + $sysProps->setLiteTopic($this->liteTopic); + } + if ($this->priority !== null) { + $sysProps->setPriority($this->priority); + } + + $message->setSystemProperties($sysProps); + } + + // Set bodyEncoding when compression was applied + if ($this->encoding !== null && $this->encoding !== Utilities::ENCODING_IDENTITY_STR) { + if (!$message->hasSystemProperties()) { + $message->setSystemProperties(new SystemProperties()); + } + $message->getSystemProperties()->setBodyEncoding( + Utilities::encodingToProtobuf($this->encoding) + ); + } + + // Set user properties + foreach ($this->properties as $key => $value) { + $message->getUserProperties()[$key] = $value; + } + + return $message; + } + + /** + * Check if any system properties are configured. + * + * @return bool True if at least one system property is set + */ + private function hasSystemProperties(): bool + { + return $this->tag !== null + || !empty($this->keys) + || $this->messageGroup !== null + || $this->deliveryTimestamp !== null + || $this->liteTopic !== null + || $this->priority !== null + || $this->encoding !== null; + } + + /** + * Validate that the new message type does not conflict with existing ones. + * + * @param string $newType The message type being set (messageGroup, deliveryTimestamp, liteTopic, or priority) + * @return void + * @throws \InvalidArgumentException if conflicting message types are already set + */ + private function validateNoConflict(string $newType) + { + $conflicts = []; + + if ($newType === 'messageGroup') { + if ($this->deliveryTimestamp !== null) $conflicts[] = 'deliveryTimestamp'; + if ($this->liteTopic !== null) $conflicts[] = 'liteTopic'; + if ($this->priority !== null) $conflicts[] = 'priority'; + } elseif ($newType === 'deliveryTimestamp') { + if ($this->messageGroup !== null) $conflicts[] = 'messageGroup'; + if ($this->liteTopic !== null) $conflicts[] = 'liteTopic'; + if ($this->priority !== null) $conflicts[] = 'priority'; + } elseif ($newType === 'liteTopic') { + if ($this->messageGroup !== null) $conflicts[] = 'messageGroup'; + if ($this->deliveryTimestamp !== null) $conflicts[] = 'deliveryTimestamp'; + if ($this->priority !== null) $conflicts[] = 'priority'; + } elseif ($newType === 'priority') { + if ($this->messageGroup !== null) $conflicts[] = 'messageGroup'; + if ($this->deliveryTimestamp !== null) $conflicts[] = 'deliveryTimestamp'; + if ($this->liteTopic !== null) $conflicts[] = 'liteTopic'; + } + + if (!empty($conflicts)) { + throw new \InvalidArgumentException( + "Message type conflict: '{$newType}' conflicts with [" . implode(', ', $conflicts) . "]. " . + "Delay, FIFO, Lite, and Priority are mutually exclusive." + ); + } + } + + /** + * Validate tag does not contain vertical bar or whitespace. + * + * @param string $tag The tag string to validate + * @return void + * @throws \InvalidArgumentException if tag contains vertical bar or whitespace + */ + private function validateTag(string $tag) + { + if (strpos($tag, '|') !== false) { + throw new \InvalidArgumentException("Tag cannot contain vertical bar '|'"); + } + if (preg_match('/\s/', $tag)) { + throw new \InvalidArgumentException("Tag cannot contain whitespace characters"); + } + } + + /** + * Validate key is not blank. + * + * @param string $key The key string to validate + * @return void + * @throws \InvalidArgumentException if the key is blank + */ + private function validateKey(string $key) + { + if (trim($key) === '') { + throw new \InvalidArgumentException("Message key cannot be blank"); + } + } +} diff --git a/php/MessageHookPoints.php b/php/MessageHookPoints.php new file mode 100644 index 000000000..2a2ea9884 --- /dev/null +++ b/php/MessageHookPoints.php @@ -0,0 +1,31 @@ +processFixedStringV1 = $this->generateProcessFixedString(); + + // Calculate seconds since custom epoch (2021-01-01 00:00:00 UTC) + $this->secondsSinceCustomEpoch = time() - $this->customEpochMillis(); + + // Record startup timestamp + $this->secondsStartTimestamp = hrtime(true); + + // Initialize current seconds + $this->seconds = $this->deltaSeconds(); + + // Initialize sequence number + $this->sequence = 0; + } + + /** + * Get singleton instance + * + * @return MessageIdCodec + */ + public static function getInstance(): MessageIdCodec + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Generate the next message ID. + * + * @return MessageId + */ + public function nextMessageId(): MessageId + { + // Calculate delta seconds + $deltaSeconds = $this->deltaSeconds(); + if ($this->seconds !== $deltaSeconds) { + $this->seconds = $deltaSeconds; + } + + // Build buffer (4-byte seconds + 4-byte sequence number) + $buffer = pack('NN', $deltaSeconds & 0xFFFFFFFF, $this->sequence++); + + // Convert to hexadecimal string + $suffix = $this->processFixedStringV1 . strtoupper(bin2hex($buffer)); + + return new MessageIdImpl(self::MESSAGE_ID_VERSION_V1, $suffix); + } + + /** + * Decode message ID string. + * + * @param string $messageId Message ID string + * @return MessageId + */ + public function decode(string $messageId): MessageId + { + if (strlen($messageId) !== self::MESSAGE_ID_LENGTH_FOR_V1_OR_LATER) { + return new MessageIdImpl(self::MESSAGE_ID_VERSION_V0, $messageId); + } + + return new MessageIdImpl(substr($messageId, 0, 2), substr($messageId, 2)); + } + + /** + * Generate process fixed string (MAC address + PID). + * + * @return string Hexadecimal string + * @throws \Exception If MAC address retrieval fails + */ + private function generateProcessFixedString(): string + { + // Get MAC address (take first 6 bytes) + $macAddress = $this->getMacAddress(); + + // Get process ID (take lower 2 bytes) + $pid = getmypid() & 0xFFFF; + + // Combine: 6-byte MAC + 2-byte PID = 8 bytes + $buffer = $macAddress . pack('n', $pid); + + return strtoupper(bin2hex($buffer)); + } + + /** + * Get MAC address. + * + * @return string 6-byte MAC address + * @throws \Exception If no suitable source of randomness is available for fallback + */ + private function getMacAddress(): string + { + // Try to read from /sys/class/net (Linux) + $macAddress = $this->readMacFromSysfs(); + + // Try ifconfig (macOS / Linux) + if ($macAddress === null) { + $macAddress = $this->readMacFromIfconfig(); + } + + // Use random value if unable to obtain + if ($macAddress === null || strlen($macAddress) < 6) { + $macAddress = random_bytes(6); + } + + return substr($macAddress, 0, 6); + } + + /** + * Read MAC address from /sys/class/net (Linux sysfs). + * + * @return string|null Binary MAC address string, or null if not available + */ + private function readMacFromSysfs(): ?string + { + $interfaces = @scandir('/sys/class/net/'); + if ($interfaces === false) { + return null; + } + foreach ($interfaces as $iface) { + if ($iface === '.' || $iface === '..' || $iface === 'lo') { + continue; + } + $mac = @file_get_contents('/sys/class/net/' . $iface . '/address'); + if ($mac !== false && strlen(trim($mac)) === 17) { + $macString = str_replace(':', '', trim($mac)); + $binary = hex2bin($macString); + if ($binary !== false) { + return $binary; + } + } + } + return null; + } + + /** + * Read MAC address from ifconfig command output (macOS/Linux fallback). + * + * @return string|null Binary MAC address string, or null if not available + */ + private function readMacFromIfconfig(): ?string + { + // ifconfig/grep/head are not available on Windows + if (\PHP_OS_FAMILY === 'Windows') { + return null; + } + + $disabled = array_map('trim', explode(',', ini_get('disable_functions'))); + if (in_array('exec', $disabled, true)) { + return null; + } + + $output = []; + exec('ifconfig 2>/dev/null | grep -E "ether|HWaddr" | head -n 1', $output); + + if (!empty($output)) { + $line = $output[0]; + if (preg_match('/([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})/', $line, $matches)) { + $macString = str_replace([':', '-'], '', $matches[0]); + $macAddress = hex2bin($macString); + if ($macAddress !== false) { + return $macAddress; + } + } + } + return null; + } + + /** + * Calculate custom epoch milliseconds (2021-01-01 00:00:00 UTC) + * + * @return int Epoch milliseconds + */ + private function customEpochMillis(): int + { + return gmmktime(0, 0, 0, 1, 1, 2021) * 1000; + } + + /** + * Calculate seconds since custom epoch + * + * @return int Number of seconds + */ + private function deltaSeconds(): int + { + $nanoTime = hrtime(true); + $elapsedSeconds = intdiv($nanoTime - $this->secondsStartTimestamp, 1000000000); + + return $this->secondsSinceCustomEpoch + $elapsedSeconds; + } +} diff --git a/php/MessageIdImpl.php b/php/MessageIdImpl.php new file mode 100644 index 000000000..0014c4cc9 --- /dev/null +++ b/php/MessageIdImpl.php @@ -0,0 +1,114 @@ +version = $version; + $this->suffix = $suffix; + } + + /** + * Get the version of the message-id. + * + * @return string The version of message-id. + */ + public function getVersion(): string + { + return $this->version; + } + + /** + * Get the suffix of the message-id. + * + * @return string The suffix of message-id. + */ + public function getSuffix(): string + { + return $this->suffix; + } + + /** + * String-formed message id. + * + * @return string String-formed message id. + */ + public function toString(): string + { + // Use suffix directly for V0 + if ($this->version === MessageIdCodec::MESSAGE_ID_VERSION_V0) { + return $this->suffix; + } + + return $this->version . $this->suffix; + } + + /** + * String conversion (magic method) + * + * @return string String-formed message id. + */ + public function __toString(): string + { + return $this->toString(); + } + + /** + * Check equality + * + * @param mixed $other Other object + * @return bool True if equal + */ + public function equals($other): bool + { + if ($this === $other) { + return true; + } + + if ($other === null || get_class($other) !== get_class($this)) { + return false; + } + + return $this->version === $other->version && $this->suffix === $other->suffix; + } + + /** + * Get hash code + * + * @return int Hash code + */ + public function hashCode(): int + { + return crc32($this->version . $this->suffix); + } +} diff --git a/php/MessageInterceptor.php b/php/MessageInterceptor.php new file mode 100644 index 000000000..e94ae9386 --- /dev/null +++ b/php/MessageInterceptor.php @@ -0,0 +1,62 @@ +interceptors[] = $interceptor; + } + + /** + * Call all registered interceptors for the given hook point. + * + * @param string $hookPoint One of MessageHookPoints constants + * @param array $context Context data specific to the hook point + * @return void + */ + public function intercept(string $hookPoint, array $context = []): void + { + foreach ($this->interceptors as $interceptor) { + $interceptor->intercept($hookPoint, $context); + } + } +} diff --git a/php/MessageValidator.php b/php/MessageValidator.php new file mode 100644 index 000000000..fadb74130 --- /dev/null +++ b/php/MessageValidator.php @@ -0,0 +1,129 @@ +maxBodySizeBytes = $maxBodySizeBytes; + $this->validateMessageType = $validateMessageType; + } + + /** + * Validate a message before sending. + * + * @param Message $message The message to validate + * @throws \InvalidArgumentException If validation fails + */ + public function validateMessage(Message $message): void + { + if (!$message->hasTopic() || empty(trim($message->getTopic()->getName()))) { + throw new \InvalidArgumentException("Message topic is required"); + } + $body = $message->getBody(); + if ($body === null || $body === '') { + throw new \InvalidArgumentException("Message body is required"); + } + if (strlen($message->getBody()) > $this->maxBodySizeBytes) { + $mb = $this->maxBodySizeBytes / (1024 * 1024); + throw new \InvalidArgumentException("Message size exceeds limit ({$mb}MB)"); + } + } + + /** + * Detect the message type based on system properties. + * + * @param Message $msg The message to inspect + * @param bool $txEnabled Whether transaction message type should be considered + * @return int MessageType constant (NORMAL, FIFO, DELAY, PRIORITY, TRANSACTION, LITE) + */ + public function detectMessageType(Message $msg, bool $txEnabled = false): int + { + $sysProps = $msg->getSystemProperties(); + $hasMessageGroup = $sysProps !== null && $sysProps->hasMessageGroup(); + $hasLiteTopic = $sysProps !== null && $sysProps->hasLiteTopic(); + $hasPriority = $sysProps !== null && $sysProps->hasPriority(); + $hasDeliveryTimestamp = $sysProps !== null && $sysProps->hasDeliveryTimestamp(); + + return match (true) { + $txEnabled && !$hasMessageGroup && !$hasLiteTopic && !$hasPriority && !$hasDeliveryTimestamp + => V2MessageType::TRANSACTION, + !$txEnabled && $hasMessageGroup => V2MessageType::FIFO, + !$txEnabled && $hasLiteTopic => V2MessageType::LITE, + !$txEnabled && $hasDeliveryTimestamp => V2MessageType::DELAY, + !$txEnabled && $hasPriority => V2MessageType::PRIORITY, + default => V2MessageType::NORMAL, + }; + } + + /** + * Check if message type validation against route is enabled. + * + * @return bool + */ + public function isValidateMessageType(): bool + { + return $this->validateMessageType; + } + + /** + * Enable or disable message type validation (updated by server settings). + * + * @param bool $validateMessageType + */ + public function setValidateMessageType(bool $validateMessageType): void + { + $this->validateMessageType = $validateMessageType; + } + + /** + * Get the maximum allowed message body size in bytes. + * + * @return int + */ + public function getMaxBodySizeBytes(): int + { + return $this->maxBodySizeBytes; + } + + /** + * Set the maximum allowed message body size in bytes (updated by server settings). + * + * @param int $maxBodySizeBytes + */ + public function setMaxBodySizeBytes(int $maxBodySizeBytes): void + { + $this->maxBodySizeBytes = $maxBodySizeBytes; + } +} diff --git a/php/MessageView.php b/php/MessageView.php new file mode 100644 index 000000000..1541a3f6d --- /dev/null +++ b/php/MessageView.php @@ -0,0 +1,435 @@ +message = $message; + if ($receiptHandle === null) { + $sysProps = $message->getSystemProperties(); + $receiptHandle = $sysProps?->getReceiptHandle() ?: null; + } + $this->receiptHandle = $receiptHandle; + $this->endpoints = $endpoints; + $this->deliveryAttempt = max(1, $deliveryAttempt); + $this->decodeTimestamp = time(); + + // Extract born timestamp and host from system properties + $sysProps = $message->getSystemProperties(); + if ($sysProps) { + $bornTs = $sysProps->getBornTimestamp(); + if ($bornTs) { + $this->bornTimestamp = $bornTs->getSeconds() ?? 0; + } + $this->bornHost = $sysProps->getBornHost() ?? ''; + + // Verify body integrity and decompress + $this->bodyStr = $this->processBody($message, $sysProps); + } + + if ($this->bodyStr === null) { + $body = $message->getBody(); + $this->bodyStr = is_string($body) ? $body : (string)$body; + } + } + + /** + * Get the topic resource. + * @return object The topic resource + */ + public function getTopicResource(): object + { + return $this->message->getTopic(); + } + + /** + * Process body: verify integrity and decompress if needed. + * + * @param Message $message The protobuf message + * @param object|null $sysProps System properties from the message + * @return string|null The processed body string, or null if processing was skipped + */ + private function processBody(Message $message, $sysProps): ?string + { + $rawBody = $message->getBody(); + $body = is_string($rawBody) ? $rawBody : (string)$rawBody; + + if ($body === '') { + return ''; + } + + // Step 1: Verify body integrity via digest. + // The digest covers the encoded (raw) body bytes, so it must be checked + // before any decompression. + if ($sysProps !== null) { + $bodyDigest = $sysProps->getBodyDigest(); + if ($bodyDigest !== null && $bodyDigest !== '') { + // getBodyDigest returns a Digest object, extract type and checksum + $digestType = is_object($bodyDigest) ? $bodyDigest->getType() : null; + $digestChecksum = is_object($bodyDigest) ? $bodyDigest->getChecksum() : (string)$bodyDigest; + if ($digestChecksum !== '' && $digestChecksum !== null) { + $this->verifyBodyDigest($body, $digestChecksum, $digestType); + } + } + } + + // Step 2: Decompress if encoding is GZIP + $encoding = Encoding::IDENTITY; + if ($sysProps !== null) { + $encoding = $sysProps->getBodyEncoding(); + } + + if ($encoding === Encoding::GZIP) { + $decompressed = @gzdecode($body); + if ($decompressed === false) { + $this->corrupted = true; + Logger::getInstance('MessageView')->warning("Failed to decompress GZIP body for messageId=" . $this->getMessageId()); + return null; + } + $body = $decompressed; + } + + return $body; + } + + /** + * Set the receipt handle for ack/nack operations. + * + * @param string|null $receiptHandle The receipt handle + * @return void + */ + public function setReceiptHandle(?string $receiptHandle): void + { + $this->receiptHandle = $receiptHandle; + } + + /** + * Verify body integrity using the given digest type and checksum. + * + * @param string $body The raw (encoded) message body bytes to verify + * @param string $checksum The expected checksum + * @param int|null $digestType The digest type (CRC32, MD5, SHA1) + * @return void + */ + private function verifyBodyDigest(string $body, string $checksum, $digestType): void + { + $computed = ''; + if ($digestType === \Apache\Rocketmq\V2\DigestType::CRC32) { + $computed = Utilities::crc32CheckSum($body); + } elseif ($digestType === \Apache\Rocketmq\V2\DigestType::MD5) { + $computed = strtoupper(md5($body)); + } elseif ($digestType === \Apache\Rocketmq\V2\DigestType::SHA1) { + $computed = strtoupper(sha1($body)); + } else { + // Unknown digest type, skip verification + return; + } + + if ($computed !== $checksum) { + $this->corrupted = true; + Logger::getInstance('MessageView')->warning( + "Body digest mismatch for messageId=" . $this->getMessageId() . + ", expected=" . $checksum . ", actual=" . $computed + ); + } + } + + /** + * Get the underlying protobuf Message. + * + * @return Message + */ + public function getMessage(): Message + { + return $this->message; + } + + /** + * Get the topic name. + * + * @return string + */ + public function getTopic(): string + { + if ($this->message->hasTopic()) { + return $this->message->getTopic()->getName(); + } + return ''; + } + + /** + * Get the message body as string. + * + * @return string + */ + public function getBody(): string + { + return $this->bodyStr ?? ''; + } + + /** + * Get the message ID from system properties. + * + * @return string + */ + public function getMessageId(): string + { + $sysProps = $this->message->getSystemProperties(); + return $sysProps?->getMessageId() ?? ''; + } + + /** + * Get the tag from system properties. + * + * @return string|null + */ + public function getTag(): ?string + { + $sysProps = $this->message->getSystemProperties(); + if ($sysProps !== null && $sysProps->hasTag()) { + return $sysProps->getTag(); + } + return null; + } + + /** + * Get the message keys from system properties. + * + * @return array + */ + public function getKeys(): array + { + $sysProps = $this->message->getSystemProperties(); + if ($sysProps !== null) { + $keys = $sysProps->getKeys(); + if ($keys === null) { + return []; + } + if ($keys instanceof \Traversable) { + return iterator_to_array($keys); + } + return (array)$keys; + } + return []; + } + + /** + * Get the message group from system properties. + * + * @return string|null + */ + public function getMessageGroup(): ?string + { + $sysProps = $this->message->getSystemProperties(); + if ($sysProps !== null && $sysProps->hasMessageGroup()) { + return $sysProps->getMessageGroup(); + } + return null; + } + + /** + * Get the receipt handle for ack/nack operations. + * + * @return string|null + */ + public function getReceiptHandle(): ?string + { + return $this->receiptHandle; + } + + /** + * Get the broker endpoints that delivered this message. + * + * @return Endpoints|null + */ + public function getEndpoints(): ?Endpoints + { + return $this->endpoints; + } + + /** + * Get the number of delivery attempts for this message. + * Starts at 1 for the first delivery. + * + * @return int + */ + public function getDeliveryAttempt(): int + { + return $this->deliveryAttempt; + } + + /** + * Get user properties. + * + * @return array + */ + public function getProperties(): array + { + $props = $this->message->getUserProperties(); + if ($props === null) { + return []; + } + if ($props instanceof \Traversable) { + return iterator_to_array($props); + } + return (array)$props; + } + + /** + * Get a specific user property by key. + * + * @param string $key The property key + * @return string|null The property value, or null if not found + */ + public function getProperty(string $key): ?string + { + $props = $this->getProperties(); + return isset($props[$key]) ? $props[$key] : null; + } + + /** + * Get the system properties from the underlying protobuf message. + * + * @return object|null The system properties object, or null if not set + */ + public function getSystemProperties(): ?object + { + return $this->message->getSystemProperties(); + } + + /** + * Check if this is a FIFO message. + * + * @return bool + */ + public function isFifo(): bool + { + return $this->getMessageGroup() !== null; + } + + /** + * Whether the message body integrity check or decompression failed. + * Corrupted messages should not be processed. + * + * @return bool + */ + public function isCorrupted(): bool + { + return $this->corrupted; + } + + /** + * Get the timestamp when the producer created this message. + * + * @return int Unix timestamp (seconds) + */ + public function getBornTimestamp(): int + { + return $this->bornTimestamp; + } + + /** + * Get the host that produced this message. + * + * @return string + */ + public function getBornHost(): string + { + return $this->bornHost; + } + + /** + * Get the timestamp when this message was decoded locally. + * + * @return int Unix timestamp (seconds) + */ + public function getDecodeTimestamp(): int + { + return $this->decodeTimestamp; + } + + /** + * Increment delivery attempt (used internally for retry tracking). + * + * @return void + */ + public function incrementDeliveryAttempt(): void + { + $this->deliveryAttempt++; + } + + /** + * Convert to string representation for logging. + * + * @return string + */ + public function __toString(): string + { + $parts = [ + 'topic=' . $this->getTopic(), + 'messageId=' . $this->getMessageId(), + ]; + $tag = $this->getTag(); + if ($tag !== null) { + $parts[] = 'tag=' . $tag; + } + $group = $this->getMessageGroup(); + if ($group !== null) { + $parts[] = 'group=' . $group; + } + if ($this->corrupted) { + $parts[] = 'CORRUPTED'; + } + return 'MessageView{' . implode(', ', $parts) . '}'; + } +} diff --git a/php/MessageViewInterface.php b/php/MessageViewInterface.php new file mode 100644 index 000000000..aab027056 --- /dev/null +++ b/php/MessageViewInterface.php @@ -0,0 +1,77 @@ +type = $type; + $this->value = $value; + } + + /** + * Resume from last consumed position. + * + * @return OffsetOption + */ + public static function lastOffset(): OffsetOption + { + return new self(self::TYPE_POLICY, self::POLICY_LAST_VALUE); + } + + /** + * Start from earliest offset. + * + * @return OffsetOption + */ + public static function minOffset(): OffsetOption + { + return new self(self::TYPE_POLICY, self::POLICY_MIN_VALUE); + } + + /** + * Start from latest offset. + * + * @return OffsetOption + */ + public static function maxOffset(): OffsetOption + { + return new self(self::TYPE_POLICY, self::POLICY_MAX_VALUE); + } + + /** + * Start from a specific offset. + * + * @param int $offset Must be >= 0 + * @return OffsetOption + * @throws \InvalidArgumentException if offset < 0 + */ + public static function ofOffset(int $offset): OffsetOption + { + if ($offset < 0) { + throw new \InvalidArgumentException("Offset must be >= 0"); + } + return new self(self::TYPE_OFFSET, $offset); + } + + /** + * Start from N messages from tail. + * + * @param int $n Must be >= 0 + * @return OffsetOption + * @throws \InvalidArgumentException if n < 0 + */ + public static function ofTailN(int $n): OffsetOption + { + if ($n < 0) { + throw new \InvalidArgumentException("Tail N must be >= 0"); + } + return new self(self::TYPE_TAIL_N, $n); + } + + /** + * Start from messages at/after a timestamp. + * + * @param int $timestamp Unix timestamp in seconds, must be >= 0 + * @return OffsetOption + * @throws \InvalidArgumentException if timestamp < 0 + */ + public static function ofTimestamp(int $timestamp): OffsetOption + { + if ($timestamp < 0) { + throw new \InvalidArgumentException("Timestamp must be >= 0"); + } + return new self(self::TYPE_TIMESTAMP, $timestamp); + } + + /** + * Get the offset type. + * + * @return string One of TYPE_POLICY, TYPE_OFFSET, TYPE_TAIL_N, TYPE_TIMESTAMP + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get the offset value. + * + * @return int Policy constant or numeric value + */ + public function getValue(): int + { + return $this->value; + } + + /** + * Check if this is a policy-based offset. + * + * @return bool + */ + public function isPolicy(): bool + { + return $this->type === self::TYPE_POLICY; + } + + /** + * Check if this is a specific offset. + * + * @return bool + */ + public function isOffset(): bool + { + return $this->type === self::TYPE_OFFSET; + } + + /** + * Check if this is a tail-N offset. + * + * @return bool + */ + public function isTailN(): bool + { + return $this->type === self::TYPE_TAIL_N; + } + + /** + * Check if this is a timestamp-based offset. + * + * @return bool + */ + public function isTimestamp(): bool + { + return $this->type === self::TYPE_TIMESTAMP; + } +} diff --git a/php/ProcessQueue.php b/php/ProcessQueue.php new file mode 100644 index 000000000..9075ac2b3 --- /dev/null +++ b/php/ProcessQueue.php @@ -0,0 +1,581 @@ +consumer = $consumer; + $this->messageQueue = $messageQueue; + $this->filterExpression = $filterExpression; + $this->activityNanoTime = hrtime(true); + $this->cacheFullNanoTime = 0; + $this->attemptId = uniqid('php-pq-', true); + $this->logger = Logger::getInstance('ProcessQueue'); + } + + /** + * Signal that messages should be fetched immediately on next loop iteration. + * + * @return void + */ + public function fetchMessageImmediately(): void + { + $this->fetchImmediately = true; + } + + /** + * Pull messages from broker. Called by the main loop. + * + * @return int Number of messages fetched + */ + public function fetchMessages(): int + { + if ($this->dropped) { + return 0; + } + + if ($this->isCacheFull()) { + $this->cacheFullNanoTime = hrtime(true); + return 0; + } + + $this->activityNanoTime = hrtime(true); + $batchSize = $this->getBatchSize(); + + $filterExpression = new FilterExpression(); + $filterExpression->setExpression($this->filterExpression); + $filterExpression->setType(\Apache\Rocketmq\V2\FilterType::TAG); + $awaitDuration = $this->consumer->getAwaitDuration(); + + $request = new ReceiveMessageRequest(); + $request->setGroup($this->consumer->getGroupResourceWithNamespace()); + $request->setMessageQueue($this->messageQueue); + $request->setFilterExpression($filterExpression); + $request->setBatchSize($batchSize); + $request->setAutoRenew(true); // PushConsumer uses server-side auto-renew + $request->setAttemptId($this->attemptId); + + $longPollingTimeout = $this->createDuration($awaitDuration); + $request->setLongPollingTimeout($longPollingTimeout); + + $requestTimeoutMs = 3000; + $awaitDurationMs = $awaitDuration * 1000; + $totalTimeoutMs = $requestTimeoutMs + $awaitDurationMs; + $metadata = $this->consumer->buildMetadata($totalTimeoutMs); + + $this->logger->debug("ProcessQueue fetching messages from queue, batchSize={$batchSize}, attemptId={$this->attemptId}"); + + $count = 0; + try { + $client = $this->getReceiveClient(); + $call = $client->ReceiveMessage($request, $metadata); + + foreach ($call->responses() as $response) { + if ($response->hasStatus()) { + $status = $response->getStatus(); + $code = $status->getCode(); + if ($code !== 20000 && $code !== 40404 && $code !== 40401) { + $this->logger->warning("ProcessQueue non-OK status: code={$code}, msg=" . $status->getMessage()); + } + } + + if ($response->hasMessage()) { + $message = $response->getMessage(); + $this->cacheMessages([$message]); + $this->receivedMessagesQuantity++; + $count++; + $this->consumeStreamedMessage($message); + } + } + + $this->receptionTimes++; + $this->attemptId = uniqid('php-pq-', true); + $this->activityNanoTime = hrtime(true); + + } catch (\Exception $e) { + if (strpos($e->getMessage(), 'DEADLINE_EXCEEDED') === false) { + $this->logger->error("ProcessQueue fetchMessages error: " . $e->getMessage()); + } + } + + $this->logger->debug("ProcessQueue fetched {$count} messages, cached=" . count($this->cachedMessages)); + return $count; + } + + /** + * Get the delivery attempt count from the consumer retry policy. + * @return int + */ + private function getMaxAttempts(): int + { + $retryPolicy = $this->consumer->getRetryPolicy(); + if ($retryPolicy instanceof RetryPolicyInterface) { + return $retryPolicy->getMaxAttempts(); + } + // Default max attempts + return 5; + } + + /** + * Consume a streamed message and handle the result (ack/nack/evict). + * + * @param object $message Protobuf message received from stream + * @return void + */ + private function consumeStreamedMessage(object $message): void + { + if ($this->consumer->getConsumeService() === null) { + return; + } + if ($this->dropped) { + return; + } + $endpoints = $this->getBrokerEndpoint(); + $messageView = new MessageView($message, null, $endpoints); + $messageId = $messageView->getMessageId() ?: 'unknown'; + + if ($messageView->isCorrupted()) { + $this->logger->error("ProcessQueue consumerStreamedMessage: message corrupted, discarding messageId={$messageId}"); + $this->consumer->nackMessage($messageView); + $this->evictMessage($messageView); + return; + } + + // Consume FIRST, then evict based on result + $result = $this->consumer->getConsumeService()->consumeMessage($messageView); + + if ($result instanceof \Apache\Rocketmq\ConsumeResultSuspend) { + $suspendSec = (int)ceil($result->getSuspendTimeMs() / 1000); + $this->logger->debug("ProcessQueue consumeStreamedMessage SUSPEND messageId={$messageId}, suspendSec={$suspendSec}"); + $this->consumer->nackMessage($messageView, 1, $suspendSec); + $this->evictMessage($messageView); + } elseif ($result === \Apache\Rocketmq\ConsumeResult::FAILURE) { + $deliveryAttempt = $messageView->getDeliveryAttempt(); + $maxAttempts = $this->getMaxAttempts(); + if ($deliveryAttempt >= $maxAttempts) { + $this->logger->warning("ProcessQueue consumeStreamedMessage FAILURE messageId={$messageId}, deliveryAttempt={$deliveryAttempt}/{$maxAttempts}, forwarding to DLQ"); + $this->consumer->getConsumeService()->forwardToDeadLetterQueue($messageView); + $this->evictMessage($messageView); + } else { + $this->logger->debug("ProcessQueue consumeStreamedMessage FAILURE messageId={$messageId}"); + $this->consumer->nackMessage($messageView, $deliveryAttempt); + $messageView->incrementDeliveryAttempt(); + } + } else { + $this->logger->debug("ProcessQueue consumeStreamedMessage SUCCESS messageId={$messageId}, ACKing immediately"); + $this->consumer->ackMessage($messageView); + $this->evictMessage($messageView); + } + } + + /** + * Get the broker endpoint from the message queue. + * + * @return object|null Broker endpoints object or null + */ + private function getBrokerEndpoint(): ?object + { + $broker = $this->messageQueue->getBroker(); + if ($broker && $broker->hasEndpoints()) { + return$broker->getEndpoints(); + } + return null; + } + + /** + * Cache received messages and track byte size. + * Also indexes by receipt handle for O(1) eviction. + * + * @param array $messages Protobuf messages to cache + * @return void + */ + private function cacheMessages(array $messages): void + { + $endpoint = $this->getBrokerEndpoint(); + foreach ($messages as $msg) { + $messageView = new MessageView($msg, null, $endpoint); + $idx = count($this->cachedMessages); + $this->cachedMessages[] = $messageView; + $body = $messageView->getBody() ?? ''; + $this->cachedMessagesBytes += strlen($body); + + // O(1) eviction index + $receiptHandle = $this->getReceiptHandle($msg); + if ($receiptHandle !== null) { + $this->cachedMessagesByReceiptHandle[$receiptHandle] = $idx; + } + } + } + + /** + * Extract receipt handle from a protobuf message. + * + * @param object $msg Protobuf message + * @return string|null Receipt handle or null if not available + */ + private function getReceiptHandle($msg): ?string + { + $sysProps = $msg->getSystemProperties(); + return $sysProps?->getReceiptHandle(); + } + + /** + * Test wrapper for cacheMessages (used by unit tests). + * + * @param array $messages + * @return void + */ + public function testCacheMessages(array $messages): void + { + $this->cacheMessages($messages); + } + + /** + * Get all cached messages. + * + * @return array + */ + public function getCachedMessages(): array + { + return $this->cachedMessages; + } + + /** + * Evict a message from cache after consumption. + * Uses O(1) swap-with-last eviction with incremental index update. + * + * @param object $messageView Message to evict + * @return void + */ + public function evictMessage(object $messageView): void + { + $objId = spl_object_id($messageView); + if (isset($this->evictedMessageIds[$objId])) { + return; + } + $this->evictedMessageIds[$objId] = true; + $idx = null; + // Try O(1) lookup by receipt handle first + $sysProps = $messageView->getSystemProperties(); + $receiptHandle = $sysProps->getReceiptHandle(); + if ($receiptHandle !== null && isset($this->cachedMessagesByReceiptHandle[$receiptHandle])) { + $idx = $this->cachedMessagesByReceiptHandle[$receiptHandle]; + unset($this->cachedMessagesByReceiptHandle[$receiptHandle]); + } + + if ($idx === null) { + // Fallback: linear scan + foreach ($this->cachedMessages as $i => $msg) { + if ($msg === $messageView) { + $idx = $i; + break; + } + } + } + if ($idx === null) { + return; + } + $body = $messageView->getBody() ?? ''; + $this->cachedMessagesBytes -= strlen($body); + if ($this->cachedMessagesBytes < 0) { + $this->cachedMessagesBytes = 0; + } + $lastIdx = count($this->cachedMessages) - 1; + if ($idx !== $lastIdx) { + $swappedMsg = $this->cachedMessages[$idx]; + $this->cachedMessages[$idx] = $this->cachedMessages[$lastIdx]; + $this->cachedMessages[$lastIdx] = $swappedMsg; + + $swappedReceipt = $this->getReceiptHandle($this->cachedMessages[$idx]); + if ($swappedReceipt !== null) { + $this->cachedMessagesByReceiptHandle[$swappedReceipt] = $idx; + } + } + array_pop($this->cachedMessages); + } + + /** + * Post-consume handling: ack or nack based on result, then evict. + * + * @param object $messageView Message to erase + * @param mixed $consumeResult ConsumeResult enum, int (0=SUCCESS, 1=FAILURE), or ConsumeResultSuspend + * @param int|null $suspendSeconds Optional suspend time in seconds (for SUSPEND result) + * @return void + */ + public function eraseMessage(object $messageView, mixed $consumeResult, ?int $suspendSeconds = null): void + { + $normalized = ($consumeResult instanceof ConsumeResult) + ? $consumeResult + : ConsumeResult::fromMixed($consumeResult); + + if ($normalized === ConsumeResult::SUCCESS) { + $this->consumer->ackMessage($messageView); + } else { + // SUSPEND uses the provided suspendSeconds; FAILURE uses default + if ($suspendSeconds !== null) { + $this->consumer->nackMessage($messageView, 1, $suspendSeconds); + } else { + $this->consumer->nackMessage($messageView); + } + } + $this->evictMessage($messageView); + } + + /** + * Discard a message immediately (nack and evict). + * + * @param MessageViewInterface $messageView + * @return void + */ + public function discardMessage(MessageViewInterface $messageView): void + { + $messageId = $messageView->getMessageId() ?: 'unknown'; + $this->logger->debug("ProcessQueue Discarding message $messageId"); + $this->consumer->nackMessage($messageView); + $this->evictMessage($messageView); + } + + /** + * Discard a FIFO message by forwarding to dead letter queue and evicting. + * + * @param MessageViewInterface $messageView + * @return void + */ + public function discardFifoMessage(MessageViewInterface $messageView): void + { + $messageId = $messageView->getMessageId() ?: 'unknown'; + $this->logger->debug("ProcessQueue discardFifoMessage message $messageId"); + if ($this->consumer->getConsumeService() !== null) { + $this->consumer->getConsumeService()->forwardToDeadLetterQueue($messageView); + } + $this->evictMessage($messageView); + } + + /** + * Check if cache is full based on per-queue thresholds. + * + * @return bool + */ + public function isCacheFull(): bool + { + $countThreshold = $this->consumer->getCacheMessageCountThresholdPerQueue(); + $bytesThreshold = $this->consumer->getCacheMessageBytesThresholdPerQueue(); + + if ($countThreshold > 0 && count($this->cachedMessages) >= $countThreshold) { + return true; + } + if ($bytesThreshold > 0 && $this->cachedMessagesBytes >= $bytesThreshold) { + return true; + } + return false; + } + + /** + * Mark this ProcessQueue as dropped. Stops fetching. + * + * @return void + */ + public function drop(): void + { + $this->dropped = true; + $this->evictedMessageIds = []; + $this->logger->info("ProcessQueue dropped for topic=" . $this->messageQueue->getTopic()->getName()); + } + + /** + * Check if this ProcessQueue has been dropped. + * + * @return bool + */ + public function isDropped(): bool + { + return $this->dropped; + } + + /** + * Check if this ProcessQueue is expired (idle for too long while cache-full). + * + * @return bool + */ + public function expired(): bool + { + $now = hrtime(true); + $longPollingTimeoutNs = $this->consumer->getAwaitDuration() * 1000000000; + $requestTimeoutNs = 3000000000; // 3s + $expiryThresholdNs = 3 * ($longPollingTimeoutNs + $requestTimeoutNs); + + if (($now - $this->activityNanoTime) > $expiryThresholdNs && + ($now - $this->cacheFullNanoTime) > $expiryThresholdNs && + $this->cacheFullNanoTime > 0) { + return true; + } + return false; + } + + /** + * Get the number of cached messages. + * + * @return int + */ + public function cachedMessagesCount(): int + { + return count($this->cachedMessages); + } + + /** + * Get the total bytes of cached messages. + * + * @return int + */ + public function cachedMessageBytes(): int + { + return $this->cachedMessagesBytes; + } + + /** + * Get the associated message queue. + * + * @return MessageQueue + */ + public function getMessageQueue(): object + { + return $this->messageQueue; + } + + /** + * Get batch size from consumer settings. + * + * @return int + */ + private function getBatchSize(): int + { + return $this->consumer->getReceiveBatchSize(); + } + + /** + * Create a Google Protobuf Duration from seconds. + * + * @param int|float $seconds + * @return Duration + */ + private function createDuration(int|float $seconds): \Google\Protobuf\Duration + { + $duration = new Duration(); + $secs = intval($seconds); + $nanos = intval(($seconds - $secs) * 1000000000); + $duration->setSeconds($secs); + $duration->setNanos($nanos); + return $duration; + } + + + /** + * Get the gRPC messaging service client for receiving messages. + * + * @return MessagingServiceClient + */ + private function getReceiveClient(): MessagingServiceClient + { + return $this->consumer->getClient(); + } + + /** + * Erase a FIFO message: ack on SUCCESS, nack with delay on SUSPEND, or forward to DLQ otherwise. + * + * @param object $messageView + * @param mixed $consumeResult ConsumeResult::SUCCESS, ConsumeResultSuspend, or ConsumeResult::FAILURE + * @return void + */ + public function eraseFifoMessage(object $messageView, mixed $consumeResult): void + { + if ($consumeResult instanceof ConsumeResultSuspend) { + $suspendSec = (int)ceil($consumeResult->getSuspendTimeMs() / 1000); + $this->consumer->nackMessage($messageView, 1, $suspendSec); + } else { + $normalized = ($consumeResult instanceof ConsumeResult) + ? $consumeResult + : ConsumeResult::fromMixed($consumeResult); + if ($normalized === ConsumeResult::SUCCESS) { + $this->consumer->ackMessage($messageView); + } else { + $this->consumer->getConsumeService()->forwardToDeadLetterQueue($messageView); + } + } + $this->evictMessage($messageView); + } + + /** + * Log stats for this ProcessQueue and reset counters. + * + * @return void + */ + public function doStats(): void + { + $reception = $this->receptionTimes; + $received = $this->receivedMessagesQuantity; + $cachedCount = count($this->cachedMessages); + $cachedBytes = $this->cachedMessagesBytes; + + $this->receptionTimes = 0; + $this->receivedMessagesQuantity = 0; + $this->logger->info("ProcessQueue: stats: topic=" . $this->messageQueue->getTopic()->getName() . " Received $received messages in $reception seconds. Cached $cachedCount messages ($cachedBytes bytes)"); + } +} diff --git a/php/Producer.php b/php/Producer.php index e785debd0..2d304f00f 100644 --- a/php/Producer.php +++ b/php/Producer.php @@ -1,73 +1,511 @@ -getRandStr(10); - $client = new MessagingServiceClient('rmq-cn-cs02xhf2k01.cn-hangzhou.rmq.aliyuncs.com:8080', [ - 'credentials' => ChannelCredentials::createInsecure(), - 'update_metadata' => function ($metaData) use ($clientId) { - $metaData['headers'] = ['clientID' => $clientId]; // Pass the ClientID to the server through the header - return $metaData; - } - ]); - - $qr = new QueryRouteRequest(); - $rs = new Resource(); - $rs->setResourceNamespace(''); - $rs->setName('normal_topic'); - $qr->setTopic($rs); - $status = $client->QueryRoute($qr)->wait(); - var_dump($status); // This prints out the response data returned by the server - } - - public function getRandStr($length){ - //Character combinations - $str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $len = strlen($str)-1; - $randstr = ''; - for ($i=0;$i<$length;$i++) { - $num=mt_rand(0,$len); - $randstr .= $str[$num]; - } - return $randstr; - } -} - -$xx = new Producer(); -$xx->init(); +settings = new ProducerSettings($endpoints, $options); + $this->validator = new MessageValidator( + $options['maxBodySizeBytes'] ?? 4194304, + $options['validateMessageType'] ?? true + ); + $this->logger = Logger::getInstance('Producer'); + + $this->client = RpcClientManager::getInstance()->getClient($endpoints, [ + 'tlsCredentials' => $this->settings->getTlsCredentials(), + 'sslEnabled' => $this->settings->isSslEnabled(), + ]); + + $this->telemetrySession = TelemetrySession::getInstance( + $this->client, $endpoints, $this->settings->getClientId(), + $this->settings->getCredentials(), $this->settings->getNamespace() + ); + $this->routeManager = new PublishingRouteManager($this->client, $endpoints, $this); + $this->heartbeatManager = new HeartbeatManager( + $this->routeManager, $this->client, $this, + $this->settings->getTlsCredentials(), $this->settings->isSslEnabled() + ); + + $metadataBuilder = fn(?int $timeoutMs = null) => $this->buildMetadata($timeoutMs); + $callOptionsResolver = fn(?int $overrideTimeout = null) => $this->getCallOptions($overrideTimeout); + $operationTimeoutFn = fn(string $op) => $this->getOperationTimeout($op); + $interceptorExecutor = function (string $hookPoint, array $context = []) { + $this->executeInterceptors($hookPoint, $context); + }; + + $this->sendHandler = new SendMessageHandler( + $this->client, + $this->settings, + $this->validator, + $this->routeManager, + $interceptorExecutor, + $metadataBuilder, + $callOptionsResolver, + $operationTimeoutFn, + ); + + $this->recallHandler = new RecallMessageHandler( + $this->client, + $this->settings, + $metadataBuilder, + $callOptionsResolver, + ); + } + + // ==================== Lifecycle ==================== + + public function start(): void + { + if ($this->isRunning) { + return; + } + + try { + Logger::getInstance('Producer')->info("Begin to start the rocketmq producer, clientId={$this->settings->getClientId()}"); + $this->establishTelemetrySession(); + $this->registerSettingsCallback(); + $this->registerTransactionCheckerCallback(); + + $this->routeManager->warmUp($this->settings->getTopics()); + + $this->isRunning = true; + $this->heartbeatManager->start(); + + Logger::getInstance('Producer')->info("The rocketmq producer starts successfully, clientId={$this->settings->getClientId()}"); + } catch (\Exception $e) { + Logger::getInstance('Producer')->error("Failed to start: " . $e->getMessage()); + $this->shutdown(); + throw $e; + } + } + + public function shutdown(): void + { + if (!$this->isRunning) { + return; + } + + $this->shutdownRequested = true; + $this->logger->info("Begin to shutdown the rocketmq producer, clientId={$this->settings->getClientId()}"); + + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + \Swoole\Coroutine::sleep(1); + } + + $this->heartbeatManager->stop(); + $this->heartbeatManager->notifyClientTermination(); + + if ($this->telemetrySession) { + $this->telemetrySession->close(); + } + + $this->isRunning = false; + $this->logger->info("Shutdown the rocketmq producer successfully, clientId={$this->settings->getClientId()}"); + } + + public function __destruct() + { + $this->shutdown(); + } + + // ==================== Send ==================== + + /** + * Send a message + * @param Message $message to send + * @return array Send result containing: + * - messageId: messageId + * - messageQueue: messageQueue + * - offset: offset + * - requestId: requestId + * - sendTime: sendTime + * - transactionId: transactionId + * - transactionState: transactionState + * @throws \Exception if producer is not running + */ + public function send(Message $message): array + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + return $this->sendHandler->send($message); + } + + /** + * Send a message asynchronously + * @param Message $message to send + * @return \Generator|mixed|null | void + */ + public function sendAsync(Message $message): array|\Generator + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + return $this->sendHandler->sendAsync($message); + } + + // ==================== Batch Send ==================== + + /** + * Send a batch of messages + * @param array $messages to send + * @return array Send result containing: + * @throws \Exception if producer is not running + */ + public function sendBatch(array $messages): array + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + return $this->sendHandler->sendBatch($messages); + } + + /** + * Send a batch of messages asynchronously + * @param array $messages to send + * @return \Generator|mixed|null | void + */ + public function sendBatchAsync(array $messages): array|\Generator + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + return $this->sendHandler->sendBatchAsync($messages); + } + + // ==================== Convenience Send Methods ==================== + + /** + * Send a priority message + * @param $topic string Topic name + * @param $body string Message body + * @param $priority int Message priority + * @param $tag string Message tag + * @return array Send result containing: + * @throws \Exception if producer is not running + */ + public function sendPriorityMessage(string $topic, string $body, int $priority, string $tag = ''): array + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + if ($priority < 1 || $priority > 9) { + throw new \InvalidArgumentException("Priority must be between 1 and 9"); + } + return $this->send($this->sendHandler->buildConvenienceMessage($topic, $body, $tag, function (SystemProperties $sp) use ($priority) { + $sp->setPriority($priority); + })); + } + + /** + * Send a delayed message + * @param $topic string Topic name + * @param $body string Message body + * @param $deliveryTimestampUnixSec Unix timestamp + * @param $tag string Message tag + * @return array Send result containing: + * @throws \Exception if producer is not running + */ + public function sendDelayedMessage(string $topic, string $body, int $deliveryTimestampUnixSec, string $tag = ''): array + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + $ts = new \Google\Protobuf\Timestamp(); + $ts->setSeconds($deliveryTimestampUnixSec); + $ts->setNanos(0); + + return $this->send($this->sendHandler->buildConvenienceMessage($topic, $body, $tag, function (SystemProperties $sp) use ($ts) { + $sp->setDeliveryTimestamp($ts); + })); + } + + /** + * Send a FIFO message + * + * @param $topic string Topic name + * @param $body string Message body + * @param $messageGroup string FIFO message group + * @param $tag string Message tag + * @return array Send result containing: + * @throws \Exception if producer is not running + */ + public function sendFifoMessage(string $topic, string $body, string $messageGroup, string $tag = ''): array + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + return $this->send($this->sendHandler->buildConvenienceMessage($topic, $body, $tag, function (SystemProperties $sp) use ($messageGroup) { + $sp->setMessageGroup($messageGroup); + })); + } + + // ==================== Recall ==================== + + /** + * Recall a message + * @param $topic string Topic name + * @param $recallHandle recall handle + * @return array recall result containing: + */ + public function recallMessage(string $topic, string $recallHandle): array + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + return $this->recallHandler->recall($topic, $recallHandle); + } + + public function recallMessageAsync(string $topic, string $recallHandle): array|\Generator + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + return $this->recallHandler->recallAsync($topic, $recallHandle); + } + + // ==================== Interceptors ==================== + + /** + * Add an interceptor + * @param MessageInterceptor $interceptor The interceptor to add + * @return $this For method chaining + * @throws \Exception if producer is not running + */ + public function addInterceptor(MessageInterceptor $interceptor): self + { + $this->interceptors[] = $interceptor; + return $this; + } + + /** + * Execute interceptors + * @param $hookPoint + * @param $context + * @return void + */ + public function executeInterceptors(string $hookPoint, array $context = []): void + { + if (empty($this->interceptors)) { + return; + } + foreach ($this->interceptors as $interceptor) { + try { + $interceptor->intercept($hookPoint, $context); + } catch (\Exception $e) { + $this->logger->warning("Interceptor failed at {$hookPoint}: " . $e->getMessage()); + } + } + } + + // ==================== Getters ==================== + + public function getClientId(): string { return $this->settings->getClientId(); } + public function isRunning(): bool { return $this->isRunning; } + + // ==================== ClientTrait Required Methods ==================== + + protected function getCredentials(): ?SessionCredentials { return $this->settings->getCredentials(); } + protected function getClientIdValue(): string { return $this->settings->getClientId(); } + protected function getNamespaceValue(): string { return $this->settings->getNamespace(); } + + // ==================== Private: Telemetry & Settings ==================== + + private function establishTelemetrySession(): void + { + $ua = new UA(); + $ua->setLanguage(Language::PHP); + $ua->setVersion(ClientConstants::CLIENT_VERSION); + + $publishing = new Publishing(); + $topicResources = []; + foreach ($this->settings->getTopics() as $topicName) { + $topicResource = new Resource(); + $topicResource->setName($topicName); + $topicResources[] = $topicResource; + } + $publishing->setTopics($topicResources); + + $settings = new Settings(); + $settings->setClientType(ClientType::PRODUCER); + $settings->setUserAgent($ua); + $settings->setPublishing($publishing); + + $command = new TelemetryCommand(); + $command->setSettings($settings); + + if (!$this->telemetrySession->syncSettings($command)) { + throw new \RuntimeException("Failed to establish Telemetry Session"); + } + SwooleCompat::sleep(500000); + } + + private function registerSettingsCallback(): void + { + $self = $this; + $this->telemetrySession->setOnSettingsChange(function ($settings) use ($self) { + $self->onServerSettings($settings); + }); + } + + private function onServerSettings(object $settings): void + { + $this->logger->info("Processing server settings"); + + if ($settings->hasPublishing()) { + $publishing = $settings->getPublishing(); + if ($publishing->getMaxBodySize() > 0) { + $this->validator->setMaxBodySizeBytes($publishing->getMaxBodySize()); + $this->logger->info("Updated maxBodySize from server: {$this->validator->getMaxBodySizeBytes()}"); + } + $this->validator->setValidateMessageType($publishing->getValidateMessageType()); + $this->logger->info("Updated validateMessageType from server: " . ($this->validator->isValidateMessageType() ? 'true' : 'false')); + } + + $this->settings->applyServerBackoffPolicy($settings, $this->logger); + } + + // ==================== TransactionTrait Delegation ==================== + // These methods provide the interface that TransactionTrait requires. + // They delegate to private properties or SendMessageHandler. + + private function validateMessage(Message $message): void + { + $this->validator->validateMessage($message); + } + + private function detectMessageType(Message $msg, bool $txEnabled = false): int + { + return $this->sendHandler->detectMessageType($msg, $txEnabled); + } + + private function wrapTransactionMessageRequest(array $messages, object $messageQueue): V2\SendMessageRequest + { + return $this->sendHandler->wrapTransactionMessageRequest($messages, $messageQueue); + } + + private function sendMessageWithRetry(V2\SendMessageRequest $request, Message $message, array $candidates, int $maxAttempts, bool $txEnabled = false): array + { + return $this->sendHandler->sendMessageWithRetry($request, $message, $candidates, $maxAttempts, $txEnabled); + } + + // ==================== TransactionTrait Infrastructure Delegation ==================== + // Protected methods so the trait can access infrastructure without touching + // private properties. Override in test fakes for isolation. + + protected function getPublishingLoadBalancer(string $topic): object + { + return $this->routeManager->getPublishingLoadBalancer($topic); + } + + protected function getIsolatedBrokerNames(): array + { + return $this->routeManager->getIsolatedBrokerNames(); + } + + protected function getSettingsMaxAttempts(): int + { + return $this->settings->getMaxAttempts(); + } + + protected function getSettingsTlsCredentials(): ?TlsCredentials + { + return $this->settings->getTlsCredentials(); + } + + protected function isSettingsSslEnabled(): bool + { + return $this->settings->isSslEnabled(); + } + + protected function getClientForRpc(): MessagingServiceClient + { + return $this->client; + } + + protected function getTelemetrySession(): TelemetrySession + { + return $this->telemetrySession; + } + + protected function getLogger(): Logger + { + return $this->logger; + } +} diff --git a/php/ProducerBuilder.php b/php/ProducerBuilder.php new file mode 100644 index 000000000..e57982ab0 --- /dev/null +++ b/php/ProducerBuilder.php @@ -0,0 +1,386 @@ +setEndpoints('127.0.0.1:8081') + * ->setTopics('TopicA', 'TopicB') + * ->setMaxAttempts(5) + * ->build(); + * + * $producer->send($message); + * $producer->shutdown(); + * ``` + * + * Usage example — transaction producer: + * ```php + * $producer = (new ProducerBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setTopics('TxTopic') + * ->setTransactionChecker(new MyTxChecker()) + * ->setLocalTransactionExecuter(new MyTxExecutor()) + * ->build(); + * + * $tx = $producer->beginTransaction(); + * $producer->sendWithTransaction($message, $tx); + * $tx->commit(); + * ``` + * + * Usage example — with ClientConfiguration: + * ```php + * $config = (new ClientConfigurationBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setSessionCredentialsProvider(new SessionCredentials('ak', 'sk')) + * ->build(); + * + * $producer = (new ProducerBuilder()) + * ->setClientConfiguration($config) + * ->setTopics('TopicA') + * ->build(); + * ``` + * + * @see Producer + * @see ClientConfiguration + * @see TransactionChecker + * @see LocalTransactionExecuter + */ +class ProducerBuilder +{ + private string $endpoints = ''; + private ?SessionCredentials $credentials = null; + private array $topics = []; + private int $maxAttempts = 3; + private int $requestTimeout = 3000; + private string $namespace = ''; + private ?TransactionChecker $transactionChecker = null; + private ?LocalTransactionExecuter $localTransactionExecuter = null; + private bool $validateMessageType = true; + private int $maxBodySizeBytes = 4194304; + private ?TlsCredentials $tlsCredentials = null; + private bool $sslEnabled = true; + + /** + * Bulk-import settings from a {@see ClientConfiguration} instance. + * + * Copies endpoints, credentials, requestTimeout, namespace, sslEnabled, and + * tlsCredentials from the config object. Individual setter calls made + * **after** this method will override the imported values. + * + * @param ClientConfiguration $config Pre-built client configuration + * @return $this For method chaining + */ + public function setClientConfiguration(ClientConfiguration $config): self + { + $this->endpoints = $config->getEndpoints(); + $this->credentials = $config->getSessionCredentialsProvider(); + $this->requestTimeout = $config->getRequestTimeoutMs(); + $this->namespace = $config->getNamespace(); + $this->sslEnabled = $config->isSslEnabled(); + if ($config->getTlsCredentials() !== null) { + $this->tlsCredentials = $config->getTlsCredentials(); + } + return $this; + } + + /** + * Set the gRPC endpoint address. + * + * Format: "host:port" or comma-separated list "host1:port1,host2:port2". + * This is a required setting; build() will throw if not set (and not + * provided via setClientConfiguration()). + * + * @param string $endpoints gRPC endpoint, e.g. "127.0.0.1:8081" + * @return $this For method chaining + */ + public function setEndpoints(string $endpoints): self + { + $this->endpoints = $endpoints; + return $this; + } + + /** + * Set AK/SK authentication credentials. + * + * When set, every gRPC request is signed with the provided Access Key and + * Secret Key. Pass null to disable authentication (only suitable for + * development/testing environments). + * + * @param SessionCredentials $credentials AK/SK credential pair + * @return $this For method chaining + * @default null (no authentication) + */ + public function setCredentials(SessionCredentials $credentials): self + { + $this->credentials = $credentials; + return $this; + } + + /** + * Declare topics to pre-warm publishing routes for. + * + * On start(), the producer fetches routing information for each declared + * topic so the first send() does not pay a route-lookup penalty. + * Topics can also be omitted here and resolved lazily on first send. + * + * @param string ...$topics One or more topic names (variadic) + * @return $this For method chaining + * @default [] (no pre-warming) + */ + public function setTopics(string ...$topics): self + { + $this->topics = $topics; + return $this; + } + + /** + * Set max retry attempts for transient send failures. + * + * Controls how many times the producer retries when SendMessage fails due + * to transient errors (network timeout, broker unavailability, etc.). + * Each retry may target a different broker queue via round-robin. + * + * @param int $maxAttempts Max retry count + * @return $this For method chaining + * @default 3 + * @valid-range Must be > 0 + * @throws \InvalidArgumentException if $maxAttempts <= 0 + */ + public function setMaxAttempts(int $maxAttempts): self + { + if ($maxAttempts <= 0) { + throw new \InvalidArgumentException("maxAttempts must be > 0"); + } + $this->maxAttempts = $maxAttempts; + return $this; + } + + /** + * Set transaction checker for orphaned transaction recovery (2PC Phase 4). + * + * Required when using beginTransaction() / sendWithTransaction(). + * The server sends a RecoverOrphanedTransaction telemetry command when it + * detects an unresolved half-message; the checker inspects the local + * transaction state and returns COMMIT or ROLLBACK. + * + * @param TransactionChecker $checker Implementation of TransactionChecker + * @return $this For method chaining + * @default null (transaction messaging disabled) + */ + public function setTransactionChecker(TransactionChecker $checker): self + { + $this->transactionChecker = $checker; + return $this; + } + + /** + * Set local transaction executor for auto commit/rollback (2PC Phase 2). + * + * When provided, sendWithTransaction() will automatically execute the local + * transaction after the half-message is sent, and commit or rollback based + * on the executor's return value (TransactionResolution::COMMIT or ROLLBACK). + * If not set, the caller must manually call $transaction->commit() or + * $transaction->rollback() after sendWithTransaction() returns. + * + * @param LocalTransactionExecuter $executer Implementation of LocalTransactionExecuter + * @return $this For method chaining + * @default null (manual commit/rollback required) + */ + public function setLocalTransactionExecuter(LocalTransactionExecuter $executer): self + { + $this->localTransactionExecuter = $executer; + return $this; + } + + /** + * Set whether to validate message type against route accept types before send. + * + * When enabled, send() / sendBatch() / sendWithTransaction() will check that + * the detected message type (NORMAL, FIFO, DELAY, PRIORITY, TRANSACTION, LITE) + * is accepted by the target broker queue. A mismatch throws before the gRPC + * call is made. This setting can also be toggled dynamically by the server + * via TelemetrySession settings push. + * + * @param bool $validate true to enable validation, false to skip + * @return $this For method chaining + * @default true + */ + public function setValidateMessageType(bool $validate): self + { + $this->validateMessageType = $validate; + return $this; + } + + /** + * Set max allowed message body size in bytes. + * + * Messages exceeding this limit are rejected with \InvalidArgumentException + * before any network call. The server may push a different limit via + * TelemetrySession settings (publishing.maxBodySize); the larger of the + * client-side and server-side values does NOT apply — the server value + * overwrites the client value when received. + * + * @param int $bytes Max body size in bytes + * @return $this For method chaining + * @default 4194304 (4 MB) + * @valid-range Must be > 0 + * @throws \InvalidArgumentException if $bytes <= 0 + */ + public function setMaxBodySizeBytes(int $bytes): self + { + if ($bytes <= 0) { + throw new \InvalidArgumentException("maxBodySizeBytes must be > 0"); + } + $this->maxBodySizeBytes = $bytes; + return $this; + } + + /** + * Set gRPC request timeout in milliseconds. + * + * Applies to all unary gRPC calls (SendMessage, RecallMessage, + * EndTransaction, etc.). For SendMessage specifically, the actual deadline + * is derived from getOperationTimeout('SEND_MESSAGE') which may differ. + * + * @param int $timeoutMs Timeout in milliseconds + * @return $this For method chaining + * @default 3000 (3 seconds) + * @valid-range Must be > 0 + * @throws \InvalidArgumentException if $timeoutMs <= 0 + */ + public function setRequestTimeout(int $timeoutMs): self + { + if ($timeoutMs <= 0) { + throw new \InvalidArgumentException("requestTimeout must be > 0"); + } + $this->requestTimeout = $timeoutMs; + return $this; + } + + /** + * Set the resource namespace prefix. + * + * When set, all topic and consumer-group resource names are prefixed with + * this namespace on the server side. Used in multi-tenant deployments to + * isolate resources across environments (e.g. "dev", "staging", "prod"). + * + * @param string $namespace Namespace string (empty string = no namespace) + * @return $this For method chaining + * @default '' (no namespace) + */ + public function setNamespace(string $namespace): self + { + $this->namespace = $namespace; + return $this; + } + + /** + * Set custom TLS credentials for the gRPC connection. + * + * When provided, the gRPC channel uses the specified CA certificate (and + * optional client cert/key) instead of the system default trust store. + * sslEnabled must be true for TLS to take effect. + * + * @param TlsCredentials $tlsCredentials TLS certificate configuration + * @return $this For method chaining + * @default null (use system trust store) + */ + public function setTlsCredentials(TlsCredentials $tlsCredentials): self + { + $this->tlsCredentials = $tlsCredentials; + return $this; + } + + /** + * Build, configure, and start the Producer. + * + * Behavior: + * 1. Validates that endpoints is set (throws \RuntimeException if empty). + * 2. Constructs a Producer with all accumulated settings. + * 3. Attaches TransactionChecker and LocalTransactionExecuter if set. + * 4. Calls Producer::start() which establishes the TelemetrySession, + * registers settings/transaction callbacks, warms up topic routes, + * and starts the HeartbeatManager. + * 5. Returns a fully started, ready-to-send Producer. + * + * After build(), the builder instance should NOT be reused — create a new + * builder for each Producer. + * + * @return Producer A started Producer instance + * @throws \RuntimeException If endpoints is not set (empty string) + * @throws \RuntimeException If Producer::start() fails (e.g. TelemetrySession + * cannot be established, gRPC connection refused) + * @throws \InvalidArgumentException If maxAttempts, requestTimeout, or + * maxBodySizeBytes was set to an invalid value + */ + public function build(): Producer + { + if ($this->endpoints === '') { + throw new \RuntimeException("Producer endpoints must be set"); + } + + $producer = new Producer($this->endpoints, [ + 'topics' => $this->topics, + 'maxAttempts' => $this->maxAttempts, + 'requestTimeout' => $this->requestTimeout, + 'namespace' => $this->namespace, + 'credentials' => $this->credentials, + 'validateMessageType' => $this->validateMessageType, + 'maxBodySizeBytes' => $this->maxBodySizeBytes, + 'tlsCredentials' => $this->tlsCredentials, + 'sslEnabled' => $this->sslEnabled, + ]); + + if ($this->transactionChecker !== null) { + $producer->setTransactionChecker($this->transactionChecker); + } + + if ($this->localTransactionExecuter !== null) { + $producer->setLocalTransactionExecuter($this->localTransactionExecuter); + } + + $producer->start(); + return $producer; + } +} diff --git a/php/ProducerSettings.php b/php/ProducerSettings.php new file mode 100644 index 000000000..78ede6130 --- /dev/null +++ b/php/ProducerSettings.php @@ -0,0 +1,130 @@ +clientId = $options['clientId'] ?? ('php-producer-' . getmypid() . '-' . time()); + $this->maxAttempts = $options['maxAttempts'] ?? 3; + $this->requestTimeout = $options['requestTimeout'] ?? 3000; + $this->topics = $options['topics'] ?? []; + $this->namespace = $options['namespace'] ?? ''; + $this->tlsCredentials = $options['tlsCredentials'] ?? null; + $this->sslEnabled = $options['sslEnabled'] ?? true; + + if (isset($options['credentials']) && $options['credentials'] instanceof SessionCredentials) { + $this->credentials = $options['credentials']; + } else { + $this->credentials = null; + } + + $this->retryPolicy = new ExponentialBackoffRetryPolicy($this->maxAttempts, 1000, 30000, 2.0); + } + + /** + * Apply server-side backoff policy settings. + * + * @param object $settings Server settings protobuf object + * @param Logger $logger Logger for diagnostics + * @return void + */ + public function applyServerBackoffPolicy(object $settings, Logger $logger): void + { + if (!$settings->hasBackoffPolicy()) { + return; + } + + $serverPolicy = $settings->getBackoffPolicy(); + $logger->info("Received backoff policy from server"); + + if ($serverPolicy->hasCustomizedBackoff()) { + $customizedBackoff = $serverPolicy->getCustomizedBackoff(); + if ($customizedBackoff !== null && !ProtobufUtil::isRepeatedFieldEmpty($customizedBackoff->getNext())) { + $this->retryPolicy = CustomizedBackoffRetryPolicy::fromProtobuf($serverPolicy); + $logger->info("Updated retry policy from server backoff"); + } + } + } + + // ==================== Getters ==================== + + public function getEndpoints(): string { return $this->endpoints; } + public function getClientId(): string { return $this->clientId; } + public function getMaxAttempts(): int { return $this->maxAttempts; } + public function getRequestTimeout(): int { return $this->requestTimeout; } + public function getTopics(): array { return $this->topics; } + public function getNamespace(): string { return $this->namespace; } + public function getCredentials(): ?SessionCredentials { return $this->credentials; } + public function getTlsCredentials(): ?TlsCredentials { return $this->tlsCredentials; } + public function isSslEnabled(): bool { return $this->sslEnabled; } + + /** + * Get the current retry policy. + * + * @return ExponentialBackoffRetryPolicy|CustomizedBackoffRetryPolicy + */ + public function getRetryPolicy(): ExponentialBackoffRetryPolicy|CustomizedBackoffRetryPolicy + { + return $this->retryPolicy; + } + + /** + * Replace the retry policy (used by server settings update). + * + * @param ExponentialBackoffRetryPolicy|CustomizedBackoffRetryPolicy $policy + */ + public function setRetryPolicy(ExponentialBackoffRetryPolicy|CustomizedBackoffRetryPolicy $policy): void + { + $this->retryPolicy = $policy; + } +} diff --git a/php/ProtobufUtil.php b/php/ProtobufUtil.php new file mode 100644 index 000000000..117f36490 --- /dev/null +++ b/php/ProtobufUtil.php @@ -0,0 +1,143 @@ + $value) { + $result[$key] = $value; + } + return $result; + } + return (array) $repeatedField; + } + + /** + * Safely check if a RepeatedField is empty. + * Replaces `empty($repeatedField)` which always returns false for objects. + * + * @param mixed $repeatedField Protobuf RepeatedField or null + * @return bool + */ + public static function isRepeatedFieldEmpty($repeatedField): bool + { + if ($repeatedField === null) { + return true; + } + if (is_array($repeatedField)) { + return empty($repeatedField); + } + if ($repeatedField instanceof \Countable) { + return $repeatedField->count() === 0; + } + return true; + } + + /** + * Safely count elements in a RepeatedField. + * More reliable than `count($repeatedField)` across protobuf versions. + * + * @param mixed $repeatedField Protobuf RepeatedField or null + * @return int + */ + public static function countRepeatedField($repeatedField): int + { + if ($repeatedField === null) { + return 0; + } + if (is_array($repeatedField)) { + return count($repeatedField); + } + if ($repeatedField instanceof \Countable) { + return $repeatedField->count(); + } + return 0; + } + + /** + * Convert a MapField to a native PHP associative array. + * + * @param mixed $mapField Protobuf MapField or null + * @return array + */ + public static function mapFieldToArray($mapField): array + { + if ($mapField === null) { + return []; + } + if (is_array($mapField)) { + return $mapField; + } + if ($mapField instanceof \Traversable) { + $result = []; + foreach ($mapField as $key => $value) { + $result[$key] = $value; + } + return $result; + } + return (array) $mapField; + } + + /** + * Safely check if a MapField is empty. + * + * @param mixed $mapField Protobuf MapField or null + * @return bool + */ + public static function isMapFieldEmpty($mapField): bool + { + if ($mapField === null) { + return true; + } + if (is_array($mapField)) { + return empty($mapField); + } + if ($mapField instanceof \Countable) { + return $mapField->count() === 0; + } + return true; + } +} diff --git a/php/PublishingLoadBalancer.php b/php/PublishingLoadBalancer.php new file mode 100644 index 000000000..1583c0066 --- /dev/null +++ b/php/PublishingLoadBalancer.php @@ -0,0 +1,202 @@ +index = mt_rand(0, PHP_INT_MAX); + + $allQueues = $routeData->getMessageQueues(); + foreach ($allQueues as $mq) { + $permission = $mq->getPermission(); + if (($permission === Permission::WRITE || $permission === Permission::READ_WRITE) + && $mq->getBroker()->getId() === ClientConstants::MASTER_BROKER_ID) { + $this->messageQueues[] = $mq; + } + } + + if (empty($this->messageQueues)) { + throw new \InvalidArgumentException("No writable message queue found"); + } + } + + /** + * Update with new route data while preserving the round-robin index. + * Matching Java's PublishingLoadBalancer.update() which reuses the AtomicInteger index. + * + * @param object $routeData New TopicRouteData + * @return PublishingLoadBalancer New instance with updated queues and preserved index + */ + public function update(object $routeData): PublishingLoadBalancer + { + $updated = new self($routeData); + $updated->index = $this->index; + return $updated; + } + + /** + * Deterministic queue selection by message group (for FIFO messages). + * Uses hash of message group to ensure same group always maps to same queue. + * + * @param string $messageGroup + * @return object|null MessageQueue + */ + public function takeMessageQueueByMessageGroup(string $messageGroup): ?object + { + if (empty($this->messageQueues)) { + return null; + } + + // Simple hash of message group string + $hash = SipHash24::hash($messageGroup); + $index = IntMath::mod($hash, count($this->messageQueues)); + + return $this->messageQueues[$index]; + } + + /** + * Round-robin queue selection with broker name exclusion. + * Excludes brokers in the isolated list on first pass, falls back to all brokers if none available. + * + * @param array $excludedBrokerNames Set of broker names to exclude (e.g. isolated/throttled) + * @param int $count Number of queues to take + * @return array Array of MessageQueue objects + */ + public function takeMessageQueue(array $excludedBrokerNames, int $count): array + { + if (empty($this->messageQueues)) { + return []; + } + + $queueCount = count($this->messageQueues); + $next = $this->index++; + $candidates = []; + $candidateBrokerNames = []; + + // First pass: exclude isolated brokers + for ($i = 0; $i < $queueCount; $i++) { + $mq = $this->messageQueues[($next + $i) % $queueCount]; + $brokerName = $mq->getBroker()->getName(); + if (!in_array($brokerName, $excludedBrokerNames, true) && !in_array($brokerName, $candidateBrokerNames, true)) { + $candidateBrokerNames[] = $brokerName; + $candidates[] = $mq; + } + if (count($candidates) >= $count) { + return $candidates; + } + } + + // Second pass: all brokers (fallback when all endpoints are isolated) + if (empty($candidates)) { + for ($i = 0; $i < $queueCount; $i++) { + $mq = $this->messageQueues[($next + $i) % $queueCount]; + $brokerName = $mq->getBroker()->getName(); + if (!in_array($brokerName, $candidateBrokerNames, true)) { + $candidateBrokerNames[] = $brokerName; + $candidates[] = $mq; + } + if (count($candidates) >= $count) { + break; + } + } + } + + return $candidates; + } + + /** + * Get all writable message queues. + * + * @return array Array of writable MessageQueue objects + */ + public function getMessageQueues(): array + { + return $this->messageQueues; + } + + /** + * Get all unique broker names from message queues. + * + * @return string[] Array of broker names + */ + public function getAllBrokerNames(): array + { + $brokerNames = []; + foreach ($this->messageQueues as $mq) { + $brokerName = $mq->getBroker()->getName(); + if (!in_array($brokerName, $brokerNames, true)) { + $brokerNames[] = $brokerName; + } + } + return $brokerNames; + } + + /** + * Validate message type against queue's accept message types. + * + * @param object $messageQueue MessageQueue protobuf object + * @param int $messageType Message type to validate + * @param string $topic Topic name for error message + * @return void + * @throws \InvalidArgumentException if message type is not accepted by the queue + */ + public function validateMessageTypeAgainstQueue(object $messageQueue, int $messageType, string $topic): void + { + if (!$messageQueue instanceof \Apache\Rocketmq\V2\MessageQueue) { + return; + } + + $acceptTypes = $messageQueue->getAcceptMessageTypes(); + if ($acceptTypes instanceof \Traversable) { + $acceptTypes = iterator_to_array($acceptTypes); + } + + if (empty($acceptTypes)) { + return; + } + + if (!in_array($messageType, $acceptTypes, true)) { + throw new \InvalidArgumentException( + "Message type not accepted for topic={$topic}, actual={$messageType}, accept=" . + implode(',', array_map('strval', $acceptTypes)) + ); + } + } +} diff --git a/php/PublishingRouteManager.php b/php/PublishingRouteManager.php new file mode 100644 index 000000000..69752f67c --- /dev/null +++ b/php/PublishingRouteManager.php @@ -0,0 +1,199 @@ +logger = Logger::getInstance('PublishingRouteManager'); + } + + // ==================== Route Cache ==================== + + public function getRouteCache(): array + { + return $this->routeCache; + } + + /** + * Get or create a PublishingLoadBalancer for a topic. + */ + public function getPublishingLoadBalancer(string $topic): PublishingLoadBalancer + { + if (!isset($this->routeCache[$topic])) { + $routeData = $this->queryRoute($topic); + $this->routeCache[$topic] = new PublishingLoadBalancer($routeData); + } + return $this->routeCache[$topic]; + } + + /** + * Refresh route cache for all known topics. + */ + public function refreshRouteCache(): void + { + foreach (array_keys($this->routeCache) as $topic) { + try { + $routeData = $this->queryRoute($topic); + $existing = $this->routeCache[$topic] ?? null; + $this->routeCache[$topic] = $existing !== null + ? $existing->update($routeData) + : new PublishingLoadBalancer($routeData); + $this->logger->debug("Route refreshed for topic={$topic}"); + } catch (\Exception $e) { + $this->logger->error("Failed to refresh route for topic={$topic}", ['exception' => $e]); + } + } + } + + /** + * Pre-populate route cache for initial topics during start(). + */ + public function warmUp(array $topics): void + { + foreach ($topics as $topic) { + $this->getPublishingLoadBalancer($topic); + } + } + + // ==================== Endpoint Collection ==================== + + /** + * Get all unique route endpoints across all cached topics. + * + * @return Endpoints[] + */ + public function getTotalRouteEndpoints(): array + { + $endpointMap = []; + foreach ($this->routeCache as $loadBalancer) { + foreach ($loadBalancer->getMessageQueues() as $messageQueue) { + $ep = $this->extractMessageQueueEndpoint($messageQueue); + if ($ep !== null) { + $endpointMap[$this->endpointsKey($ep)] = $ep; + } + } + } + return array_values($endpointMap); + } + + // ==================== Endpoint Isolation ==================== + + public function isolateEndpoints(Endpoints $endpoints): void + { + foreach ($endpoints->getAddresses() as $address) { + $key = $address->getHost() . ':' . $address->getPort(); + $this->isolatedEndpoints[$key] = $endpoints; + } + } + + public function clearIsolatedEndpoints(): void + { + $this->isolatedEndpoints = []; + } + + public function getIsolatedEndpoints(): array + { + return $this->isolatedEndpoints; + } + + /** + * Get broker names that are currently isolated. + */ + public function getIsolatedBrokerNames(): array + { + $brokerNames = []; + foreach ($this->routeCache as $loadBalancer) { + foreach ($loadBalancer->getMessageQueues() as $messageQueue) { + $ep = $this->extractMessageQueueEndpoint($messageQueue); + if ($ep !== null) { + $key = $this->endpointsKey($ep); + if (isset($this->isolatedEndpoints[$key])) { + $brokerNames[] = $messageQueue->getBroker()->getName(); + } + } + } + } + return array_unique($brokerNames); + } + + // ==================== Helpers ==================== + + public function endpointsKey(Endpoints $endpoints): string + { + $addresses = $endpoints->getAddresses(); + if (!empty($addresses) && $addresses[0] !== null) { + return $addresses[0]->getHost() . ':' . $addresses[0]->getPort(); + } + return spl_object_hash($endpoints); + } + + public static function extractMessageQueueEndpoint($messageQueue): ?Endpoints + { + $broker = $messageQueue->getBroker(); + if ($broker && $broker->hasEndpoints()) { + return $broker->getEndpoints(); + } + return null; + } + + // ==================== Private ==================== + + private function queryRoute(string $topic) + { + $topicResource = new Resource(); + $topicResource->setName($topic); + + $request = new QueryRouteRequest(); + $request->setTopic($topicResource); + $request->setEndpoints($this->traitProvider->parseEndpoints($this->endpoints)); + + $timeoutMs = (int)($this->traitProvider->getOperationTimeout('QUERY_ROUTE') / 1000); + $metadata = $this->traitProvider->buildMetadata($timeoutMs); + $callOptions = ['timeout' => $this->traitProvider->getOperationTimeout('QUERY_ROUTE')]; + + list($response, $status) = $this->client->QueryRoute($request, $metadata, $callOptions)->wait(); + + if ($status->code !== 0) { + throw new \RuntimeException("Query route failed: " . $status->details); + } + return $response; + } +} diff --git a/php/PushConsumer.php b/php/PushConsumer.php new file mode 100644 index 000000000..f8a503777 --- /dev/null +++ b/php/PushConsumer.php @@ -0,0 +1,1348 @@ +, topic subscription map (topic => expression) + * - maxCacheMessageCount: int, max cached messages in memory (default: 4096) + * - maxCacheMessageSizeInBytes: int, max cached message total size (default: 67108864, 64MB) + * - awaitDuration: int, long polling timeout in seconds (default: 5) + * - scanIntervalSeconds: int, assignment scan interval in seconds (default: 5) + * - fifo: bool, enable FIFO message consumption mode (default: false) + * - receiveBatchSize: int, max messages per receive batch (default: 32) + * - enableFifoConsumeAccelerator: bool, enable FIFO consume accelerator (default: false) + * - isLiteConsumer: bool, enable lite consumer mode (default: false) + * - credentials: SessionCredentials|null, AK/SK authentication credentials + * - namespace: string, resource namespace prefix (default: '') + * - tlsCredentials: TlsCredentials|null, TLS/SSL configuration + * - sslEnabled: bool, enable SSL for gRPC channel (default: true) + */ + public function __construct( + protected readonly string $endpoints, + protected readonly string $consumerGroup, + array $options = [] + ) { + if (empty($consumerGroup)) { + throw new \InvalidArgumentException("PushConsumer consumerGroup cannot be empty"); + } + $this->clientId = $options['clientId'] ?? ('php-push-consumer-' . getmypid() . '-' . time()); + $this->messageListener = $options['messageListener'] ?? null; + $this->subscriptionExpressions = $options['subscriptionExpressions'] ?? []; + $this->maxCacheMessageCount = $options['maxCacheMessageCount'] ?? 4096; + $this->maxCacheMessageSizeInBytes = $options['maxCacheMessageSizeInBytes'] ?? 67108864; + $this->awaitDuration = $options['awaitDuration'] ?? 5; + $this->scanIntervalSeconds = $options['scanIntervalSeconds'] ?? 5; + $this->fifo = $options['fifo'] ?? false; + $this->receiveBatchSize = $options['receiveBatchSize'] ?? 32; + $this->enableFifoConsumeAccelerator = $options['enableFifoConsumeAccelerator'] ?? false; + $this->isLiteConsumer = $options['isLiteConsumer'] ?? false; + $this->namespace = $options['namespace'] ?? ''; + $this->tlsCredentials = $options['tlsCredentials'] ?? null; + $this->sslEnabled = $options['sslEnabled'] ?? true; + + // Set AK/SK credentials if provided + $this->credentials = (isset($options['credentials']) && $options['credentials'] instanceof SessionCredentials) + ? $options['credentials'] + : null; + + $this->logger = Logger::getInstance('PushConsumer'); + + // Use RpcClientManager for connection pooling + $this->client = RpcClientManager::getInstance()->getClient($endpoints, [ + 'tlsCredentials' => $this->tlsCredentials, + 'sslEnabled' => $options['sslEnabled'] ?? true, + ]); + + $this->telemetrySession = TelemetrySession::getInstance($this->client, $endpoints, $this->clientId, $this->credentials, $this->namespace); + $this->retryPolicy = new ExponentialBackoffRetryPolicy(5, 1000, 30000, 2.0); + } + + /** + * Check if this is a FIFO consumer. + * @return bool + */ + public function fifo(): bool + { + return $this->fifo; + } + + /** + * Subscribe to a topic. + * + * @param string $topic Topic name + * @param string $expression Filter expression (default "*") + * @return $this + */ + public function subscribe(string $topic, string $expression = '*'): self + { + if ($this->isRunning) { + // Dynamic runtime subscription: update subscription expressions + $this->subscriptionExpressions[$topic] = $expression; + $this->logger->info("Dynamically subscribed to topic: {$topic}"); + return $this; + } + $this->subscriptionExpressions[$topic] = $expression; + return $this; + } + + /** + * Get the retry policy. + * + * @return ExponentialBackoffRetryPolicy|null + */ + public function getRetryPolicy(): ?ExponentialBackoffRetryPolicy + { + return $this->retryPolicy; + } + + /** + * Unsubscribe from a topic. + * + * @param string $topic Topic name + * @return $this + */ + public function unsubscribe(string $topic): self + { + if ($this->isRunning) { + // Dynamic runtime unsubscription + unset($this->subscriptionExpressions[$topic]); + unset($this->cacheAssignments[$topic]); + // Drop related ProcessQueues + $processQueue = $this->processQueueTable; + foreach ($processQueue as $key => $pq) { + $mq = $pq->getMessageQueue(); + if ($mq->getTopic()->getName() === $topic) { + $pq->drop(); + unset($this->processQueueTable[$key]); + } + } + $this->logger->info("Dynamically unsubscribed from topic: {$topic}"); + return $this; + } + unset($this->subscriptionExpressions[$topic]); + unset($this->cacheAssignments[$topic]); + return $this; + } + + /** + * Set the message listener callback. + * + * @param callable $listener function($messageView): int + * @return $this + */ + public function setMessageListener(callable $listener): self + { + $this->checkNotRunning(); + $this->messageListener = $listener; + return $this; + } + + /** + * Start the PushConsumer. Blocks in the main polling loop. + * + * @throws \RuntimeException If messageListener or subscriptions are not set + */ + public function start(): void + { + if ($this->isRunning) { + return; + } + + if ($this->messageListener === null) { + throw new \RuntimeException("PushConsumer messageListener is not set"); + } + + if (empty($this->subscriptionExpressions)) { + throw new \RuntimeException("PushConsumer has no subscriptions"); + } + + $this->logger->info("PushConsumer starting, clientId={$this->clientId}"); + + try { + $this->establishTelemetrySession(); + + // Register settings change callback + $this->registerSettingsCallback(); + + $this->onStartBeforeLoop(); + + // Create consume service (Standard, FIFO, or LiteFIFO) + if ($this->isLiteConsumer) { + $this->consumeService = new LiteFifoConsumeService($this->logger, $this->messageListener, $this, $this->enableFifoConsumeAccelerator); + } elseif ($this->fifo) { + $this->consumeService = new FifoConsumeService($this->logger, $this->messageListener, $this, $this->enableFifoConsumeAccelerator); + } else { + $this->consumeService = new StandardConsumeService($this->logger, $this->messageListener, $this); + } + + $this->registerSignalHandlers(); + $this->isRunning = true; + + $this->logger->info("PushConsumer started successfully, clientId={$this->clientId}"); + + // Initial assignment scan + $this->scanAssignments(); + + // Main polling loop + $lastScanTime = time(); + + while ($this->isRunning && !$this->shutdownRequested) { + // Dispatch pending signals + if (function_exists('pcntl_signal_dispatch')) { + pcntl_signal_dispatch(); + } + + if ($this->telemetrySession) { + $this->telemetrySession->pollTelemetry(); + } + if ($this->shutdownRequested) { + break; + } + + $now = time(); + if ($now - $lastScanTime >= $this->scanIntervalSeconds) { + $this->scanAssignments(); + $this->onScanCycleComplete(); + $lastScanTime = $now; + } + + // Periodic heartbeat + $this->onHeartbeatTick(); + + // Fetch messages from each active ProcessQueue + $this->fetchMessageInterleavedHeartbeat(); + // Short sleep between iterations + SwooleCompat::sleep(100000); + + // Periodic garbage collection + gc_collect_cycles(); + } + + // Graceful shutdown drain phase + $this->drainInFlightMessages(); + + $this->shutdown(); + + } catch (\Exception $e) { + $this->logger->error("PushConsumer start failed: " . $e->getMessage()); + $this->onStop(); + throw $e; + } + } + + /** + * Start the PushConsumer with a timeout. Blocks for at most the given seconds. + * + * @param int $seconds Maximum duration in seconds + * @return void + * @throws \RuntimeException If messageListener or subscriptions are not set + */ + public function startWithTimeout(int $seconds): void + { + if ($this->isRunning()) { + return; + } + + if ($this->messageListener === null) { + throw new \RuntimeException("PushConsumer messageListener is not set"); + } + if (empty($this->subscriptionExpressions)) { + throw new \RuntimeException("PushConsumer has no subscriptions"); + } + $this->logger->info("PushConsumer starting with timeout {$seconds} seconds, clientId={$this->clientId}"); + try { + $this->establishTelemetrySession(); + $this->registerSettingsCallback(); + $this->onStartBeforeLoop(); + if ($this->isLiteConsumer) { + $this->consumeService = new LiteFifoConsumeService($this->logger, $this->messageListener, $this, $this->enableFifoConsumeAccelerator); + } elseif ($this->fifo) { + $this->consumeService = new FifoConsumeService($this->logger, $this->messageListener, $this, $this->enableFifoConsumeAccelerator); + } else { + $this->consumeService = new StandardConsumeService($this->logger, $this->messageListener, $this); + } + $this->registerSignalHandlers(); + $this->isRunning = true; + $this->logger->info("PushConsumer running with timeout {$seconds} seconds, clientId={$this->clientId}"); + $this->scanAssignments(); + $deadline = time() + $seconds; + $lastScanTime = time(); + while ($this->isRunning && !$this->shutdownRequested && time() < $deadline) { + if (function_exists('pcntl_signal_dispatch')) { + pcntl_signal_dispatch(); + } + if ($this->telemetrySession) { + $this->telemetrySession->pollTelemetry(); + } + if ($this->shutdownRequested) { + break; + } + $now = time(); + if ($now - $lastScanTime >= $this->scanIntervalSeconds) { + $this->scanAssignments(); + $this->onScanCycleComplete(); + $lastScanTime = $now; + } + + $this->onHeartbeatTick(); + + $this->fetchMessageInterleavedHeartbeat(); + SwooleCompat::sleep(100000); + gc_collect_cycles(); + } + + $this->logger->info("PushConsumer startWithTimeout completed after {$seconds}s, clientId={$this->clientId}"); + $this->onStop(); + } catch (\Exception $e) { + $this->logger->error("PushConsumer startWithTimeout failed: " . $e->getMessage()); + $this->onStop(); + throw $e; + } + } + + /** + * Get the consume service instance. + * + * @return ConsumeService|null + */ + public function getConsumeService(): ?ConsumeService + { + return $this->consumeService; + } + + /** + * Hook called before the main polling loop starts. Override in subclasses. + * + * @return void + */ + protected function onStartBeforeLoop(): void + { + + } + + /** + * Hook called when the consumer stops. Override in subclasses. + * + * @return void + */ + protected function onStop(): void + { + + } + + /** + * Create a heartbeat request with client type and group. + * + * @return HeartbeatRequest + */ + private function wrapHeartbeatRequest(): \Apache\Rocketmq\V2\HeartbeatRequest + { + $request = new HeartbeatRequest(); + $request->setClientType($this->getClientType()); + $request->setGroup($this->getGroupResource()); + return $request; + } + + /** + * Fetch messages from each active ProcessQueue, interleaved with heartbeat ticks. + * + * @return void + */ + private function fetchMessageInterleavedHeartbeat(): void + { + $processQueues = $this->processQueueTable; + foreach ($processQueues as $key => $pq) { + if ($pq->isDropped() || $pq->expired()) { + $pq->drop(); + unset($this->processQueueTable[$key]); + continue; + } + if (!$pq->isCacheFull()) { + $this->onHeartbeatTick(); + $pq->fetchMessages(); + } + } + } + + /** + * Drain in-flight messages before shutdown. Waits up to 30s for cached messages + * to be consumed, preventing message loss on abrupt termination. + */ + private function drainInFlightMessages(): void + { + $drainStart = microtime(true); + $drainTimeout = 30; // seconds + $drainIterations = 0; + + $this->logger->info("PushConsumer drain phase: waiting for in-flight messages to be consumed"); + + // Step 1: Mark all queues as dropped to stop fetching NEW messages + // This prevents new messages from being added to the cache + $processQueues = $this->processQueueTable; + foreach ($processQueues as $pq) { + $pq->drop(); + } + + // Step 2: Wait for cached messages to be consumed + // Note: ConsumeService checks isDropped() at the START of consume(), + // but since we call it AFTER drop(), it will skip consumption. + // Solution: Manually iterate and consume cached messages here, + // bypassing the isDropped() check in ConsumeService. + while (microtime(true) - $drainStart < $drainTimeout) { + $remainingCount = 0; + $activeQueues = 0; + + foreach ($this->processQueueTable as $pq) { + $messages = $pq->getCachedMessages(); + $cachedCount = count($messages); + $remainingCount += $cachedCount; + + if ($cachedCount > 0 && $this->consumeService !== null) { + $activeQueues++; + + // Manually consume each cached message, bypassing isDropped() check + // We copy the list first to avoid modification during iteration + $toConsume = array_values($messages); + foreach ($toConsume as $messageView) { + // Skip if already evicted + if (!in_array($messageView, $pq->getCachedMessages(), true)) { + continue; + } + + try { + // Call the message listener directly + $result = $this->consumeService->consumeMessage($messageView); + + // Handle result : SUCCESS, SUSPEND, or FAILURE + if ($result === \Apache\Rocketmq\ConsumeResult::SUCCESS) { + $this->consumeService->ackMessage($messageView); + $pq->evictMessage($messageView); + } elseif ($result instanceof \Apache\Rocketmq\ConsumeResultSuspend) { + // Respect the suspend time during drain + $suspendSec = (int)ceil($result->getSuspendTimeMs() / 1000); + $this->consumeService->nackMessage($messageView, 1, $suspendSec); + $pq->evictMessage($messageView); + } else { + $this->consumeService->nackMessage($messageView); + $pq->evictMessage($messageView); + } + + // Evict from cache + $pq->evictMessage($messageView); + } catch (\Exception $e) { + $this->logger->error("Drain phase consume error: " . $e->getMessage()); + // On error, nack and evict + try { + $this->consumeService->nackMessage($messageView); + } catch (\Exception $ackError) { + $this->logger->warning("Failed to nack message during drain: " . $ackError->getMessage()); + } + $pq->evictMessage($messageView); + } + } + } + } + + if ($remainingCount === 0) { + $this->logger->info("PushConsumer drain phase completed after {$drainIterations} iterations, all messages consumed"); + return; + } + + // Log progress every 50 iterations (~5 seconds) + if ($drainIterations % 50 === 0 && $drainIterations > 0) { + $elapsed = round(microtime(true) - $drainStart, 1); + $this->logger->info("PushConsumer drain progress: {$remainingCount} messages remaining in {$activeQueues} queues after {$elapsed}s"); + } + + SwooleCompat::sleep(100000); // 100ms + $drainIterations++; + } + + $remainingCount = 0; + $processQueues = $this->processQueueTable; + foreach ($processQueues as $pq) { + $remainingCount += count($pq->getCachedMessages()); + } + $this->logger->warning("PushConsumer drain phase timed out after {$drainTimeout}s, {$remainingCount} messages remaining in " . count($this->processQueueTable) . " queues"); + } + + /** + * Request graceful shutdown. + */ + public function shutdown(): void + { + if (!$this->isRunning) { + return; + } + + $this->logger->info("PushConsumer shutting down, clientId={$this->clientId}"); + + $this->isRunning = false; + + // Notify server of client termination + $this->notifyClientTermination(); + + // Drop all ProcessQueues + foreach ($this->processQueueTable as $pq) { + $pq->drop(); + } + $this->processQueueTable = []; + + // Close telemetry session + if ($this->telemetrySession) { + $this->telemetrySession->close(); + } + + $this->logger->info("PushConsumer shutdown complete, clientId={$this->clientId}"); + } + + /** + * Signal handler for graceful shutdown. + */ + public function requestShutdown(): void + { + $this->shutdownRequested = true; + } + + /** + * Register a message interceptor. + * + * @param MessageInterceptor $interceptor + * @return $this + */ + public function addInterceptor(MessageInterceptor $interceptor): self + { + $this->interceptors[] = $interceptor; + return $this; + } + + /** + * Execute interceptors at a given hook point. + * + * @param string $hookPoint The hook point identifier + * @param array $context Additional context for the interceptor + * @return void + */ + public function executeInterceptors(string $hookPoint, array $context = []): void + { + if (empty($this->interceptors)) { + return; + } + foreach ($this->interceptors as $interceptor) { + try { + $interceptor->intercept($hookPoint, $context); + } catch (\Exception $e) { + $this->logger->warning("Interceptor failed at {$hookPoint}: " . $e->getMessage()); + } + } + } + + /** + * Get the client type identifier. + * + * @return int The PUSH_CONSUMER client type + */ + protected function getClientType(): int + { + return ClientType::PUSH_CONSUMER; + } + + /** + * Register SIGTERM/SIGINT signal handlers. + */ + protected function registerSignalHandlers(): void + { + if (function_exists('pcntl_signal')) { + $self = $this; + pcntl_signal(SIGTERM, function() use ($self) { + $self->requestShutdown(); + }); + pcntl_signal(SIGINT, function() use ($self) { + $self->requestShutdown(); + }); + $this->logger->info("PushConsumer signal handlers registered"); + } + } + + /** + * Establish Telemetry Session with the server for this consumer group. + * + * @return void + * @throws \RuntimeException If session establishment fails + */ + protected function establishTelemetrySession(): void + { + $ua = new UA(); + $ua->setLanguage(Language::PHP); + $ua->setVersion(ClientConstants::CLIENT_VERSION); + + $subscriptionEntries = []; + foreach ($this->subscriptionExpressions as $topic => $expression) { + $filterExpression = new FilterExpression(); + $filterExpression->setExpression($expression); + + $topicResource = new Resource(); + $topicResource->setName($topic); + + $subscriptionEntry = new SubscriptionEntry(); + $subscriptionEntry->setTopic($topicResource); + $subscriptionEntry->setExpression($filterExpression); + + $subscriptionEntries[] = $subscriptionEntry; + } + + $subscription = new Subscription(); + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + $subscription->setGroup($groupResource); + $subscription->setSubscriptions($subscriptionEntries); + + $settings = new Settings(); + $settings->setClientType($this->getClientType()); + $settings->setUserAgent($ua); + $settings->setSubscription($subscription); + + $settings->setAccessPoint($this->parseEndpoints($this->endpoints)); + $timeoutDuration = new Duration(); + $timeoutDuration->setSeconds(3); + $timeoutDuration->setNanos(0); + $settings->setRequestTimeout($timeoutDuration); + + $command = new TelemetryCommand(); + $command->setSettings($settings); + + // syncSettings() waits for the server's Settings response (with timeout) + // instead of only creating the stream, so consumption never starts before + // the server has accepted the settings and returned backoff policies. + $success = $this->telemetrySession->syncSettings($command); + if (!$success) { + throw new \RuntimeException("Failed to establish Telemetry Session"); + } + } + + /** + * Scan assignments for all subscribed topics. + */ + private function scanAssignments(): void + { + $this->logger->debug("PushConsumer scanning assignments"); + + $subscriptions = $this->subscriptionExpressions; + foreach ($subscriptions as $topic => $expression) { + try { + $assignments = $this->queryAssignment($topic); + $newAssignments = $assignments ? ProtobufUtil::repeatedFieldToArray($assignments->getAssignments()) : []; + + $oldAssignments = isset($this->cacheAssignments[$topic]) ? $this->cacheAssignments[$topic] : null; + $newIsEmpty = empty($newAssignments); + $oldIsEmpty = $oldAssignments === null || empty($oldAssignments); + if ($newIsEmpty && $oldIsEmpty) { + $this->logger->debug("PushConsumer acquired empty assignment from remote, would scan later, for topic $topic"); + continue; + } + $this->syncProcessQueues($topic, $newAssignments, $expression); + $this->cacheAssignments[$topic] = $newAssignments; + } catch (\Exception $e) { + $this->logger->warning("PushConsumer scanAssignments failed for topic={$topic}: " . $e->getMessage()); + } + } + } + + /** + * Sync ProcessQueues with the latest assignments, creating new queues and dropping stale ones. + * + * @param string $topic Topic name + * @param array $newAssignments Latest assignment list from the server + * @param string $expression Filter expression for the topic + * @return void + */ + private function syncProcessQueues(string $topic, array $newAssignments, string $expression): void + { + $latestMQKeys = []; + foreach ($newAssignments as $assignment) { + $mq = $assignment->getMessageQueue(); + $mqKey = $this->getMqKey($mq); + $latestMQKeys[$mqKey] = $mq; + } + if (empty($newAssignments)) { + // An empty assignment set is a valid rebalance result (e.g. all queues were + // reassigned to other consumers); fall through so the removal loop below + // drops every ProcessQueue that belongs to this topic. + $existingCount = count($this->processQueueTable); + if ($existingCount > 0) { + $this->logger->warning("Broker returned 0 assignments for topic={$topic}, dropping this topic's existing ProcessQueues"); + } + } + + // Drop ProcessQueues no longer in the latest assignments + $processQueues = $this->processQueueTable; + foreach ($processQueues as $key => $pq) { + $pqMq = $pq->getMessageQueue(); + $pqTopic = $pqMq->getTopic()->getName(); + if ($pqTopic !== $topic) { + continue; + } + if (!isset($latestMQKeys[$key])) { + $pq->drop(); + unset($this->processQueueTable[$key]); + $this->logger->info("PushConsumer dropped ProcessQueue: {$key}"); + } + } + + // Create new ProcessQueues for new assignments + foreach ($latestMQKeys as $key => $mq) { + $alreadyExists = false; + $processQueues = $this->processQueueTable; + foreach ($processQueues as $existingKey => $existingPq) { + if ($existingKey === $key) { + $alreadyExists = true; + break; + } + } + + if (!$alreadyExists) { + $pq = new ProcessQueue($this, $mq, $expression); + $this->processQueueTable[$key] = $pq; + $pq->fetchMessageImmediately(); + $this->logger->info("PushConsumer created ProcessQueue: {$key}"); + } + } + } + + /** + * Query assignment for a topic via QueryAssignment gRPC. + * + * @param string $topic + * @return QueryAssignmentResponse|null + */ + private function queryAssignment(string $topic): ?\Apache\Rocketmq\V2\QueryAssignmentResponse + { + $topicResource = new Resource(); + $topicResource->setName($topic); + + $request = new QueryAssignmentRequest(); + $request->setTopic($topicResource); + $request->setEndpoints($this->parseEndpoints($this->endpoints)); + + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + $request->setGroup($groupResource); + + $metadata = $this->buildMetadata(ClientConstants::GRPC_DEFAULT_TIMEOUT / 1000); + + list($response, $status) = $this->client->QueryAssignment($request, $metadata, $this->getCallOptions())->wait(); + + if ($status->code !== 0) { + throw new \RuntimeException("QueryAssignment failed for topic={$topic}: " . $status->details); + } + + $assignmentCount = $response->getAssignments() ? ProtobufUtil::countRepeatedField($response->getAssignments()) : 0; + $this->logger->debug("PushConsumer QueryAssignment for {$topic}: {$assignmentCount} assignments"); + + return $response; + } + + /** + * Generate a unique key for a MessageQueue. + * + * @param MessageQueue $mq + * @return string + */ + private function getMqKey(object $mq): string + { + $topicName = $mq->hasTopic() ? $mq->getTopic()->getName() : 'unknown'; + $queueId = $mq->getId() ?? 0; + $brokerName = 'default'; + if ($mq->hasBroker()) { + $broker = $mq->getBroker(); + $brokerName = $broker->getName() ?: 'default'; + } + return "{$topicName}:{$brokerName}:{$queueId}"; + } + + /** + * Check if the consumer is currently running. + * + * @return bool + */ + public function isRunning(): bool + { + return $this->isRunning; + } + + /** + * Get the subscription expressions map. + * + * @return array ['topic' => 'expression', ...] + */ + public function getSubscriptionExpressions(): array + { + return $this->subscriptionExpressions; + } + + /** + * Get the underlying MessagingServiceClient. + * + * @return MessagingServiceClient + */ + public function getClient(): ?MessagingServiceClient + { + return $this->client; + } + + /** + * Get the Client ID. + * + * @return string + */ + public function getClientId(): string + { + return $this->clientId; + } + + /** + * Get the consumer group Resource object. + * + * @return Resource + */ + public function getGroupResource(): Resource + { + $resource = new Resource(); + $resource->setName($this->consumerGroup); + return $resource; + } + + /** + * Get a Resource object for a topic, including namespace if set. + * + * @param string $topic Topic name + * @return Resource + */ + public function getTopicResource(string $topic): Resource + { + $resource = new Resource(); + if ($this->namespace !== '') { + $resource->setResourceNamespace($this->namespace); + } + $resource->setName($topic); + return $resource; + } + + /** + * Get the consumer group Resource object with namespace if set. + * + * @return Resource + */ + public function getGroupResourceWithNamespace(): Resource + { + $resource = new Resource(); + if ($this->namespace !== '') { + $resource->setResourceNamespace($this->namespace); + } + $resource->setName($this->consumerGroup); + return $resource; + } + + /** + * Get the session credentials for AK/SK authentication. + * + * @return SessionCredentials|null + */ + public function getSessionCredentials(): ?SessionCredentials + { + return $this->credentials; + } + + /** + * Hook called after each scan cycle. Override in subclasses for periodic tasks. + */ + protected function onScanCycleComplete(): void + { + // No-op in base class + } + + /** + * Get the namespace. + * + * @return string + */ + public function getNamespace(): string + { + return $this->namespace; + } + + /** + * Get the await duration in seconds. + * + * @return int + */ + public function getAwaitDuration(): int + { + return $this->awaitDuration; + } + + /** + * Get the receive batch size. + * + * @return int + */ + public function getReceiveBatchSize(): int + { + return $this->receiveBatchSize; + } + + /** + * Get per-queue cache message count threshold. + * + * @return int + */ + public function getCacheMessageCountThresholdPerQueue(): int + { + $size = count($this->processQueueTable); + if ($size <= 0) { + return 0; + } + return max(1, (int)($this->maxCacheMessageCount / $size)); + } + + /** + * Get per-queue cache byte size threshold. + * + * @return int + */ + public function getCacheMessageBytesThresholdPerQueue(): int + { + $size = count($this->processQueueTable); + if ($size <= 0) { + return 0; + } + return max(1, (int)($this->maxCacheMessageSizeInBytes / $size)); + } + + /** + * Acknowledge a message via gRPC. + * + * @param MessageView $messageView + * @return bool True if ACK was sent successfully, false if consumeService is not initialized + */ + public function ackMessage(MessageView $messageView): bool + { + if ($this->consumeService === null) { + $this->logger->warning("PushConsumer ackMessage: consume service not initialized"); + return false; + } + $messageId = $messageView->getMessageId() ?: 'unknown'; + $this->logger->debug("PushConsumer ackMessage: delegating to consumeService for messageId: {$messageId}"); + return $this->consumeService->ackMessage($messageView); + } + + /** + * Reject a message (change invisible duration for retry). + * + * @param MessageView $messageView + * @param int $deliveryAttempt Current delivery attempt count + * @param int|null $invisibleDuration Next invisible duration in seconds + * @return bool + */ + public function nackMessage(MessageView $messageView, int $deliveryAttempt = 1, ?int $invisibleDuration = null): bool + { + if ($this->consumeService === null) { + $this->logger->warning("PushConsumer nackMessage: consume service not initialized"); + return false; + } + return $this->consumeService->nackMessage($messageView, $deliveryAttempt, $invisibleDuration); + } + + /** + * Check that the consumer is not yet running. Throws if already started. + * + * @return void + * @throws \RuntimeException If the consumer is already running + */ + protected function checkNotRunning() + { + if ($this->isRunning) { + throw new \RuntimeException("PushConsumer is already running"); + } + } + + /** + * Get session credentials for AK/SK authentication (required by ClientTrait). + * + * @return SessionCredentials|null + */ + protected function getCredentials(): ?SessionCredentials + { + return $this->credentials; + } + + /** + * Get the client ID value for ClientTrait. + * + * @return string + */ + protected function getClientIdValue(): string + { + return $this->clientId; + } + + /** + * Get the namespace value for ClientTrait. + * + * @return string + */ + protected function getNamespaceValue(): string + { + return $this->namespace; + } + + /** + * Register settings change callback on the Telemetry session. + */ + protected function registerSettingsCallback() + { + $self = $this; + $this->telemetrySession->setOnSettingsChange(function ($settings) use ($self) { + $self->onServerSettings($settings); + }); + $this->telemetrySession->setOnVerifyMessage(function ($verifyCmd) use ($self) { + return $self->onVerifyMessage($verifyCmd); + }); + } + + /** + * Handle server-pushed Settings (backoff policy, subscription config). + * + * @param Settings $settings Server settings protobuf message + * @return void + */ + private function onServerSettings($settings) + { + $this->logger->info("Processing server settings"); + + // Process backoff policy + if ($settings->hasBackoffPolicy()) { + $serverPolicy = $settings->getBackoffPolicy(); + $this->logger->info("Received backoff policy from server"); + if ($serverPolicy->hasCustomizedBackoff()) { + $customizedBackoff = $serverPolicy->getCustomizedBackoff(); + if ($customizedBackoff !== null && !ProtobufUtil::isRepeatedFieldEmpty($customizedBackoff->getNext())) { + $delays = []; + foreach ($customizedBackoff->getNext() as $dur) { + $delays[] = $dur->getSeconds() * 1000; + } + if (!empty($delays)) { + $this->retryPolicy = CustomizedBackoffRetryPolicy::fromProtobuf($serverPolicy); + $this->logger->info("Updated retry policy from server backoff"); + } + } + } + } + + // Process subscription settings + if ($settings->hasSubscription()) { + $sub = $settings->getSubscription(); + if ($sub->getReceiveBatchSize() > 0) { + $oldBatchSize = $this->receiveBatchSize; + $this->receiveBatchSize = $sub->getReceiveBatchSize(); + $this->logger->info("Server set receiveBatchSize: {$oldBatchSize} -> {$this->receiveBatchSize}"); + } + } + } + + /** + * Send heartbeat to all route endpoints. + */ + private function doHeartbeat() + { + $metadata = $this->buildMetadata(ClientConstants::GRPC_DEFAULT_TIMEOUT / 1000); + try { + list($response, $status) = $this->client->Heartbeat($this->wrapHeartbeatRequest(), $metadata, $this->getCallOptions())->wait(); + if ($status->code === 0) { + $this->logger->info("Heartbeat success, broker: {$this->endpoints}"); + } else { + $this->logger->warning("Heartbeat failed, broker: {$this->endpoints}"); + } + } catch (\Exception $e) { + $this->logger->warning("Heartbeat failed, broker: {$this->endpoints}, error: {$e->getMessage()}"); + } + if (empty($this->processQueueTable)) { + return; + } + + $request = $this->wrapHeartbeatRequest(); + $endpointsMap = []; + $processQueues = $this->processQueueTable; + foreach ($processQueues as $pq) { + $mq = $pq->getMessageQueue(); + $broker = $mq->getBroker(); + if ($broker && $broker->hasEndpoints()) { + $endpoints = $broker->getEndpoints(); + $addresses = $endpoints->getAddresses(); + if (!empty($addresses) && $addresses[0] !== null) { + $key = $addresses[0]->getHost() . ':' . $addresses[0]->getPort(); + $endpointsMap[$key] = $endpoints; + } + } + } + foreach ($endpointsMap as $brokerKey => $endpoints) { + $metadata = $this->buildMetadata(ClientConstants::GRPC_DEFAULT_TIMEOUT / 1000); + try { + $brokerClient = RpcClientManager::getInstance()->getClient($brokerKey, [ + 'tlsCredentials' => $this->tlsCredentials, + 'sslEnabled' => $this->sslEnabled, + ]); + list($response, $status) = $brokerClient->Heartbeat($request, $metadata, $this->getCallOptions())->wait(); + if ($status->code === 0) { + $this->logger->info("Heartbeat success, broker: {$brokerKey}"); + } else { + $this->logger->warning("Heartbeat failed, broker: {$brokerKey}, status: {$status->code}"); + } + } catch (\Exception $e) { + $this->logger->warning("Heartbeat failed, broker: {$brokerKey}, error: {$e->getMessage()}"); + } + } + } + + /** + * Notify server that this client is terminating. + */ + private function notifyClientTermination() + { + $request = new NotifyClientTerminationRequest(); + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + $request->setGroup($groupResource); + + $metadata = $this->buildMetadata(ClientConstants::GRPC_DEFAULT_TIMEOUT / 1000); + + try { + list($response, $status) = $this->client->NotifyClientTermination($request, $metadata, $this->getCallOptions())->wait(); + if ($status->code === 0) { + $this->logger->debug("NotifyClientTermination sent successfully"); + } else { + $this->logger->warning("NotifyClientTermination failed: " . $status->details); + } + } catch (\Exception $e) { + $this->logger->warning("NotifyClientTermination exception: " . $e->getMessage()); + } + } + + /** + * Heartbeat tick handler - called from main loop. + */ + protected function onHeartbeatTick() + { + // Concurrency guard: prevent overlapping heartbeat executions + if ($this->heartbeatInProgress) { + return; + } + $this->heartbeatInProgress = true; + try { + $now = time(); + if ($now - $this->lastHeartbeatTime >= 10) { + $this->doHeartbeat(); + static $lastRouteRefresh = 0; + if ($now - $lastRouteRefresh >= 30) { + $this->refreshRouteCache(); + $lastRouteRefresh = $now; + } + $this->lastHeartbeatTime = $now; + } + } finally { + $this->heartbeatInProgress = false; + } + } + + /** + * Start the PushConsumer in an async coroutine (requires Swoole). + * + * @param callable|null $onDone Optional callback invoked when the consumer stops + * @return bool True if started in coroutine, false if fell back to synchronous start + */ + public function startAsync(?callable $onDone = null): bool + { + if (!SwooleCompat::isAvailable()) { + $this->logger->warning("startAsync: Swoole/OneSwoole not available, fallback to start()"); + $this->start(); + return false; + } + $this->logger->info("PushConsumer starting in async coroutine, clientId={$this->clientId}"); + $self = $this; + $channel = new \Swoole\Coroutine\Channel(1); + \Swoole\Coroutine::create(function () use ($self, $channel, $onDone) { + try { + $self->start(); + } catch (\Throwable $e) { + $self->logger->error("PushConsumer startAsync failed, clientId={$self->clientId}, error={$e->getMessage()}"); + } + if ($onDone !== null) { + try { + $onDone(); + } catch (\Throwable $e) { + $self->logger->error("PushConsumer startAsync onDone failed, clientId={$self->clientId}, error={$e->getMessage()}"); + } + } + $channel->push(true); + }); + return true; + } + + /** + * Refresh the route cache for all subscribed topics via QueryAssignment. + * + * @return void + */ + private function refreshRouteCache() + { + foreach ($this->subscriptionExpressions as $topic => $expression) { + try { + $this->queryAssignment($topic); + $this->logger->debug("Route refreshed for topic={$topic}"); + } catch (\Throwable $e) { + $this->logger->warning("Route refreshed failed, topic={$topic}, error={$e->getMessage()}"); + } + } + } + + /** + * Handle server-pushed message verification command. + * + * @param VerifyMessageCommand $verifyCmd The verification command from the server + * @return \Apache\Rocketmq\V2\TelemetryCommand|null Response command or null on failure + */ + private function onVerifyMessage(VerifyMessageCommand $verifyCmd) + { + $message = $verifyCmd->getMessage(); + if ($message === null) { + $this->logger->warning("PushConsumer onVerifyMessage no message in verify command"); + return null; + } + try { + $messageView = new MessageView($message, null, null, 1); + if ($messageView->isCorrupted()) { + $this->logger->error("PushConsumer onVerifyMessage message is corrupted"); + $status = new \Apache\Rocketmq\V2\Status(); + $status->setCode(50000); + $status->setMessage("message is corrupted"); + $result = new \Apache\Rocketmq\V2\VerifyMessageResult(); + $result->setNonce($verifyCmd->getNonce()); + $resp = new \Apache\Rocketmq\V2\TelemetryCommand(); + $resp->setStatus($status); + $resp->setVerifyMessageResult($result); + return $resp; + } + $result = $this->consumeService->consumeMessage($messageView); + $code = ($result === ConsumeResult::SUCCESS) ? 20000 : 40000; + $status = new \Apache\Rocketmq\V2\Status(); + $status->setCode($code); + $verifyResult = new \Apache\Rocketmq\V2\VerifyMessageResult(); + $verifyResult->setNonce($verifyCmd->getNonce()); + + $resp = new \Apache\Rocketmq\V2\TelemetryCommand(); + $resp->setStatus($status); + $resp->setVerifyMessageResult($verifyResult); + return $resp; + } catch (\Exception $e) { + $this->logger->warning("PushConsumer onVerifyMessage failed: " . $e->getMessage()); + return null; + } + } +} diff --git a/php/PushConsumerBuilder.php b/php/PushConsumerBuilder.php new file mode 100644 index 000000000..5a55ffe4f --- /dev/null +++ b/php/PushConsumerBuilder.php @@ -0,0 +1,472 @@ + 'filterExpression'] map + * - maxCacheMessageCount: 4096 Max number of messages buffered in memory + * - maxCacheMessageSizeInBytes: 67108864 Max total size of buffered messages (64 MB) + * - awaitDuration: 30 Long-poll wait time in seconds + * - consumptionThreadCount: 1 Thread count (no-op in PHP, stored for API parity) + * - fifo: false Enable strict FIFO consumption ordering + * - enableFifoConsumeAccelerator: false Parallel processing by messageGroup + * - enableMessageInterceptorFiltering: false Client-side interceptor filtering + * - namespace: '' Resource namespace prefix + * - credentials: null AK/SK SessionCredentials for authentication + * - tlsCredentials: null TlsCredentials for custom TLS configuration + * + * Usage example — basic push consumer: + * ```php + * $consumer = (new PushConsumerBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setConsumerGroup('my-group') + * ->subscribe('TopicA', '*') + * ->setMessageListener(function (MessageView $mv): int { + * echo $mv->getBody(); + * return ConsumeResult::SUCCESS; + * }) + * ->build(); + * ``` + * + * Usage example — FIFO consumer with bounded duration: + * ```php + * (new PushConsumerBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setConsumerGroup('fifo-group') + * ->setFifo(true) + * ->setEnableFifoConsumeAccelerator(true) + * ->subscribe('FifoTopic') + * ->setMessageListener(function (MessageView $mv): int { + * return ConsumeResult::SUCCESS; + * }) + * ->startFor(60); // run for 60 seconds, then shutdown + * ``` + * + * Usage example — with ClientConfiguration and async start: + * ```php + * $config = (new ClientConfigurationBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setSessionCredentialsProvider(new SessionCredentials('ak', 'sk')) + * ->build(); + * + * $consumer = (new PushConsumerBuilder()) + * ->setClientConfiguration($config) + * ->setConsumerGroup('my-group') + * ->subscribe('TopicA') + * ->setMessageListener(fn(MessageView $mv) => ConsumeResult::SUCCESS) + * ->buildAsync(); + * ``` + * + * @see PushConsumer + * @see ClientConfiguration + * @see ConsumeResult + */ +class PushConsumerBuilder +{ + private string $endpoints = ''; + private string $consumerGroup = ''; + private ?SessionCredentials $credentials = null; + private array $subscriptionExpressions = []; + /** @var callable|null */ + private $messageListener = null; + private int $maxCacheMessageCount = 4096; + private int $maxCacheMessageSizeInBytes = 67108864; + private int $awaitDuration = 30; + private int $consumptionThreadCount = 1; // no-op in PHP, stored for API parity + private bool $enableFifoConsumeAccelerator = false; + private bool $enableMessageInterceptorFiltering = false; + private string $namespace = ''; + private bool $fifo = false; + private ?TlsCredentials $tlsCredentials = null; + + /** + * Bulk-import settings from a {@see ClientConfiguration} instance. + * + * Copies endpoints, credentials, namespace, and tlsCredentials from the + * config object. Individual setter calls made **after** this method will + * override the imported values. + * + * @param ClientConfiguration $config Pre-built client configuration + * @return $this For method chaining + */ + public function setClientConfiguration(ClientConfiguration $config): self + { + $this->endpoints = $config->getEndpoints(); + $this->credentials = $config->getSessionCredentialsProvider(); + $this->namespace = $config->getNamespace(); + if ($config->getTlsCredentials() !== null) { + $this->tlsCredentials = $config->getTlsCredentials(); + } + return $this; + } + + /** + * Enable or disable strict FIFO consumption ordering. + * + * When FIFO mode is enabled, messages within the same messageGroup are + * delivered to the listener in order, and the next message in a group is + * not dispatched until the current one is acknowledged. This is essential + * for ordered message processing (e.g. trade order state machine). + * + * Typically paired with setEnableFifoConsumeAccelerator(true) for parallel + * processing across different messageGroups. + * + * @param bool $fifo true to enable FIFO ordering, false for best-effort + * @return $this For method chaining + * @default false + */ + public function setFifo(bool $fifo): self + { + $this->fifo = $fifo; + return $this; + } + /** + * Set the consumer group name. + * + * The consumer group identifies this consumer on the server side. All + * consumers sharing the same group name will load-balance messages; + * consumers in different groups each receive a full copy of every message + * (broadcast semantics). This is a required setting. + * + * @param string $consumerGroup Consumer group name (must match server-side group config) + * @return $this For method chaining + * @default '' (empty — buildWithoutStart() will throw) + */ + public function setConsumerGroup(string $consumerGroup): self + { + $this->consumerGroup = $consumerGroup; + return $this; + } + + /** + * Bulk-set subscription expressions (topic => filter expression map). + * + * Replaces any previously registered subscriptions. Filter expressions + * follow the RocketMQ SQL92 or tag syntax: + * - '*': match all messages + * - 'tagA': match messages with tag "tagA" + * - 'tagA || tagB': match messages with tag "tagA" or "tagB" + * - SQL92: 'color = "red" AND price > 100' + * + * At least one subscription is required; buildWithoutStart() throws if empty. + * + * @param array $expressions Associative array ['topicName' => 'filterExpression'] + * @return $this For method chaining + * @default [] (no subscriptions) + * @see subscribe() for adding subscriptions one by one + */ + public function setSubscriptionExpressions(array $expressions): self + { + $this->subscriptionExpressions = $expressions; + return $this; + } + + /** + * Register a subscription for a single topic. + * + * Convenience method that adds to (not replaces) the subscription map. + * Calling subscribe('TopicA', '*') then subscribe('TopicB', 'tagX') is + * equivalent to setSubscriptionExpressions(['TopicA' => '*', 'TopicB' => 'tagX']). + * Subscribing to the same topic twice overwrites the previous expression. + * + * @param string $topic Topic name to subscribe to + * @param string $expression Filter expression (tag or SQL92) + * @return $this For method chaining + * @default expression is '*' (match all) + */ + public function subscribe(string $topic, string $expression = '*'): self + { + $this->subscriptionExpressions[$topic] = $expression; + return $this; + } + + /** + * Set the message listener callback invoked for each received message. + * + * The listener receives a {@see MessageView} and must return a ConsumeResult: + * - ConsumeResult::SUCCESS — message processed, will be acknowledged + * - ConsumeResult::FAILURE — processing failed, message will be retried + * according to the server-side retry policy + * + * The listener should be fast and non-blocking; heavy work should be + * dispatched to a separate worker pool. This is a required setting. + * + * @param callable $listener Signature: function(MessageView $mv): int + * @return $this For method chaining + * @default null (buildWithoutStart() will throw) + */ + public function setMessageListener(callable $listener): self + { + $this->messageListener = $listener; + return $this; + } + + /** + * Set max number of messages buffered in memory awaiting listener dispatch. + * + * Controls the prefetch buffer size. A larger value increases throughput + * at the cost of higher memory usage and potential message redelivery on + * consumer crash. When the buffer is full, long-poll ReceiveMessage calls + * are paused until messages are consumed. + * + * @param int $count Max cached message count + * @return $this For method chaining + * @default 4096 + * @valid-range Must be > 0 + * @throws \InvalidArgumentException if $count <= 0 + */ + public function setMaxCacheMessageCount(int $count): self + { + if ($count <= 0) { + throw new \InvalidArgumentException("maxCacheMessageCount must be > 0"); + } + $this->maxCacheMessageCount = $count; + return $this; + } + + /** + * Set max total size of messages buffered in memory (in bytes). + * + * Secondary flow-control limit alongside maxCacheMessageCount. When the + * cumulative body size of cached messages exceeds this threshold, the + * consumer pauses prefetching until messages are consumed. + * + * @param int $bytes Max cached message size in bytes + * @return $this For method chaining + * @default 67108864 (64 MB) + * @valid-range Must be > 0 + * @throws \InvalidArgumentException if $bytes <= 0 + */ + public function setMaxCacheMessageSizeInBytes(int $bytes): self + { + if ($bytes <= 0) { + throw new \InvalidArgumentException("maxCacheMessageSizeInBytes must be > 0"); + } + $this->maxCacheMessageSizeInBytes = $bytes; + return $this; + } + + /** + * Set consumption thread count (stored for API parity, no-op in PHP). + * + * In the Java/Go SDK this controls the thread pool size for concurrent + * message dispatch. PHP uses an event-loop model (Swoole coroutines or + * synchronous dispatch), so this value is stored but does not create + * OS threads. It is kept for cross-language configuration compatibility. + * + * @param int $count Thread count hint + * @return $this For method chaining + * @default 1 + */ + public function setConsumptionThreadCount(int $count): self + { + $this->consumptionThreadCount = $count; + return $this; + } + + /** + * Enable FIFO consume accelerator for parallel processing by messageGroup. + * + * When enabled, messages from different messageGroups are dispatched to + * the listener concurrently (each group still processes in order). This + * significantly improves throughput for FIFO topics with many distinct + * message groups. Only effective when setFifo(true) is also set. + * + * @param bool $enable true to enable parallel group processing + * @return $this For method chaining + * @default false + */ + public function setEnableFifoConsumeAccelerator(bool $enable): self + { + $this->enableFifoConsumeAccelerator = $enable; + return $this; + } + + /** + * Enable client-side message interceptor filtering. + * + * When enabled, registered MessageInterceptors are applied during the + * receive path before the listener is invoked. Interceptors can inspect, + * transform, or filter messages (e.g. drop messages that don't match + * custom business rules). When disabled, interceptors only run on the + * send/ack path. + * + * @param bool $enable true to apply interceptors during receive + * @return $this For method chaining + * @default false + */ + public function setEnableMessageInterceptorFiltering(bool $enable): self + { + $this->enableMessageInterceptorFiltering = $enable; + return $this; + } + + /** + * Set the resource namespace prefix. + * + * @param string $namespace Namespace string (empty string = no namespace) + * @return $this For method chaining + * @default '' (no namespace) + */ + public function setNamespace(string $namespace): self + { + $this->namespace = $namespace; + return $this; + } + + /** + * Set custom TLS credentials for the gRPC connection. + * + * @param TlsCredentials $tlsCredentials TLS certificate configuration + * @return $this For method chaining + * @default null (use system trust store) + */ + public function setTlsCredentials(TlsCredentials $tlsCredentials): self + { + $this->tlsCredentials = $tlsCredentials; + return $this; + } + + /** + * Build the PushConsumer without starting it. + * + * Validates all required fields and constructs a PushConsumer instance. + * The returned consumer is fully configured but NOT running — call + * start(), startAsync(), or startWithTimeout() separately. + * + * This is useful when you need to register additional interceptors or + * perform setup before message delivery begins. + * + * Validation rules (all throw \RuntimeException): + * - endpoints must be set (non-empty) + * - consumerGroup must be set (non-empty) + * - messageListener must be set (non-null callable) + * - at least one subscription must be registered + * + * @return PushConsumer A configured but unstarted PushConsumer + * @throws \RuntimeException If any required field is missing + */ + public function buildWithoutStart(): PushConsumer + { + if ($this->endpoints === '') { + throw new \RuntimeException("PushConsumer endpoints must be set"); + } + if ($this->consumerGroup === '') { + throw new \RuntimeException("PushConsumer consumerGroup must be set"); + } + if ($this->messageListener === null) { + throw new \RuntimeException("PushConsumer messageListener must be set"); + } + if (empty($this->subscriptionExpressions)) { + throw new \RuntimeException("PushConsumer must have at least one subscription"); + } + + return new PushConsumer($this->endpoints, $this->consumerGroup, [ + 'subscriptionExpressions' => $this->subscriptionExpressions, + 'messageListener' => $this->messageListener, + 'maxCacheMessageCount' => $this->maxCacheMessageCount, + 'maxCacheMessageSizeInBytes' => $this->maxCacheMessageSizeInBytes, + 'awaitDuration' => $this->awaitDuration, + 'fifo' => $this->fifo, + 'enableFifoConsumeAccelerator' => $this->enableFifoConsumeAccelerator, + 'namespace' => $this->namespace, + 'credentials' => $this->credentials, + 'tlsCredentials' => $this->tlsCredentials, + ]); + } + + /** + * Build and start the PushConsumer synchronously (blocking). + * + * Equivalent to: + * $consumer = $builder->buildWithoutStart(); + * $consumer->start(); // blocks until TelemetrySession is established + * return $consumer; + * + * The returned consumer is actively receiving and dispatching messages. + * + * @return PushConsumer A started, message-receiving PushConsumer + * @throws \RuntimeException If any required field is missing + * @throws \RuntimeException If start() fails (e.g. gRPC connection refused, + * TelemetrySession cannot be established) + */ + public function build(): PushConsumer + { + $consumer = $this->buildWithoutStart(); + $consumer->start(); + return $consumer; + } + + /** + * Build, start, run for a fixed duration, then shutdown — all in one call. + * + * Convenient for short-lived consumers, CLI tools, and integration tests. + * Blocks the current thread for the specified duration, then gracefully + * shuts down the consumer. Messages in-flight at shutdown time are + * negatively acknowledged and redelivered to other consumers. + * + * @param int $seconds Duration in seconds to consume messages + * @return void + * @throws \RuntimeException If any required field is missing + * @throws \RuntimeException If startWithTimeout() fails + */ + public function startFor(int $seconds): void + { + $consumer = $this->buildWithoutStart(); + $consumer->startWithTimeout($seconds); + $consumer->shutdown(); + } + + /** + * Build and start the PushConsumer asynchronously (non-blocking). + * + * Starts the consumer in a Swoole coroutine (when available) or returns + * immediately. The optional $onDone callback is invoked once the startup + * sequence completes. Messages begin arriving as soon as the TelemetrySession + * is established. + * + * Use this when you need to start multiple consumers or run other logic + * concurrently in the same process. + * + * @param callable|null $onDone Optional callback invoked after start completes. + * Signature: function(): void + * @return PushConsumer The consumer (may not be fully started yet) + * @throws \RuntimeException If any required field is missing + */ + public function buildAsync(?callable $onDone = null): PushConsumer + { + $consumer = $this->buildWithoutStart(); + $consumer->startAsync($onDone); + return $consumer; + } +} diff --git a/php/RecallMessageHandler.php b/php/RecallMessageHandler.php new file mode 100644 index 000000000..e2f486dfb --- /dev/null +++ b/php/RecallMessageHandler.php @@ -0,0 +1,127 @@ +logger = Logger::getInstance('Producer'); + } + + /** + * Recall a previously sent message. + * + * @param string $topic Topic name + * @param string $recallHandle Recall handle from send result + * @return array{messageId: string, status: object} Recall result + * @throws \RuntimeException If the gRPC call fails + */ + public function recall(string $topic, string $recallHandle): array + { + $topicResource = new Resource(); + $topicResource->setName($topic); + + $request = new RecallMessageRequest(); + $request->setTopic($topicResource); + $request->setRecallHandle($recallHandle); + + $metadata = ($this->metadataBuilder)($this->settings->getRequestTimeout()); + list($response, $status) = $this->client->RecallMessage( + $request, $metadata, ($this->callOptionsResolver)() + )->wait(); + + if ($status->code !== 0) { + throw new \RuntimeException("Recall message failed: " . $status->details); + } + + return [ + 'messageId' => $response->getMessageId() ?? '', + 'status' => $response->getStatus(), + ]; + } + + /** + * Recall a message asynchronously. + * + * Uses Swoole coroutine when available; falls back to Generator otherwise. + * + * @param string $topic Topic name + * @param string $recallHandle Recall handle from send result + * @return array|\Generator + * @throws \RuntimeException On timeout or gRPC failure + */ + public function recallAsync(string $topic, string $recallHandle): array|\Generator + { + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + $channel = new \Swoole\Coroutine\Channel(1); + \Swoole\Coroutine::create(function () use ($topic, $recallHandle, $channel) { + try { + $result = $this->recall($topic, $recallHandle); + $channel->push(['success' => true, 'result' => $result]); + } catch (\Throwable $e) { + $channel->push(['success' => false, 'exception' => $e]); + } + }); + $data = $channel->pop($this->settings->getRequestTimeout() / 1000.0); + if ($data === false) { + throw new \RuntimeException( + "Recall message async Request timeout {$this->settings->getRequestTimeout()}ms" + ); + } + if (isset($data['exception'])) { + throw $data['exception']; + } + return $data['result'] ?? null; + } + return $this->recallSyncFallback($topic, $recallHandle); + } + + /** + * Generator fallback for recallAsync when Swoole is not available. + * + * @param string $topic + * @param string $recallHandle + * @return \Generator + */ + private function recallSyncFallback(string $topic, string $recallHandle): \Generator + { + yield $this->recall($topic, $recallHandle); + } +} diff --git a/php/RetryPolicyInterface.php b/php/RetryPolicyInterface.php new file mode 100644 index 000000000..caf0c575e --- /dev/null +++ b/php/RetryPolicyInterface.php @@ -0,0 +1,50 @@ +logger = Logger::getInstance('RpcClientManager'); + $this->lastCheckTime = time(); + } + + /** + * Get the singleton instance, creating it if necessary. + * + * @return self + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Reset the singleton instance (primarily for testing). + * + * @return void + */ + public static function reset(): void + { + self::$instance = null; + } + + /** + * Get or create a MessagingServiceClient for the given endpoints. + * + * Clients are cached and reused based on endpoint + resolved transport/TLS mode. + * Idle clients are automatically cleaned up every 60 seconds if unused for 30 minutes. + * + * @param string $endpoints Server endpoint in format "host:port" + * @param array $options Optional configuration: + * - 'tlsCredentials': TlsCredentials instance for TLS/mTLS + * - 'credentials': Pre-created ChannelCredentials + * - 'sslEnabled': bool, default TLS on/off when no credentials given + * @return MessagingServiceClient gRPC client instance + */ + public function getClient(string $endpoints, array $options = []): MessagingServiceClient + { + if (trim($endpoints) === '') { + throw new \InvalidArgumentException('endpoints must not be empty'); + } + + // Check mock registry first; mocks match by endpoint regardless of TLS options + if (isset($this->mocks[$endpoints])) { + $this->clientLastUsedTime[$endpoints] = time(); + return $this->mocks[$endpoints]; + } + + $credentials = $this->resolveCredentials($options); + $key = $this->makeKey($endpoints, $options); + + if (!isset($this->clients[$key])) { + $this->logger->info("Creating new RPC client for: {$endpoints}"); + $opts = ['credentials' => $credentials]; + + // Merge channel args from TlsCredentials (e.g., SSL target name override for dev) + if (isset($options['tlsCredentials']) && $options['tlsCredentials'] instanceof TlsCredentials) { + // Extract host from endpoints for ssl_target_name_override + $targetHost = $endpoints; + if (str_contains($endpoints, ':')) { + $targetHost = explode(":", $endpoints)[0]; + } + $opts = array_merge($opts, $options['tlsCredentials']->getChannelArgs($targetHost)); + } + + $this->clients[$key] = new MessagingServiceClient($endpoints, $opts); + } + + $this->clientLastUsedTime[$key] = time(); + + // Periodically clean up idle connections + $now = time(); + if ($now - $this->lastCheckTime >= $this->checkIntervalSeconds) { + $this->cleanupIdleClients(); + $this->lastCheckTime = $now; + } + + return $this->clients[$key]; + } + + /** + * Register a mock MessagingServiceClient for the given endpoints. + * Subsequent calls to getClient() with matching endpoints will return this mock, + * regardless of the TLS options passed to getClient(). + * + * @param string $endpoints Server endpoint in format "host:port" + * @param MessagingServiceClient $mock The mock client to return + * @return void + */ + public function registerMock(string $endpoints, MessagingServiceClient $mock): void + { + $this->mocks[$endpoints] = $mock; + $this->logger->info("Registered mock client for: {$endpoints}"); + } + + /** + * Remove all registered mocks. + * + * @return void + */ + public function clearMocks(): void + { + $this->mocks = []; + } + + /** + * Release a specific client connection by endpoint prefix. + * + * All clients whose key starts with the given endpoint will be removed. + * + * @param string $endpoints Endpoint prefix to match (e.g., "localhost:8080") + * @return void + */ + public function releaseClient(string $endpoints): void + { + $keysToRemove = []; + foreach ($this->clients as $key => $client) { + if (strpos($key, $endpoints) === 0) { + $keysToRemove[] = $key; + } + } + + foreach ($keysToRemove as $key) { + unset($this->clients[$key]); + unset($this->clientLastUsedTime[$key]); + $this->logger->info("Released RPC client: {$key}"); + } + } + + /** + * Release all client connections and clear the cache. + * + * @return void + */ + public function releaseAll(): void + { + $count = count($this->clients); + $this->clients = []; + $this->clientLastUsedTime = []; + $this->logger->info("Released all {$count} RPC clients"); + } + + /** + * Get the number of active connections in the pool. + * + * @return int Number of cached client connections + */ + public function getConnectionCount(): int + { + return count($this->clients); + } + + /** + * Clean up idle client connections that haven't been used for more than idleTimeoutSeconds. + * + * This method is called automatically every checkIntervalSeconds (60s) when getClient() is invoked. + * + * @return void + */ + private function cleanupIdleClients(): void + { + $now = time(); + $keysToRemove = []; + + foreach ($this->clientLastUsedTime as $key => $lastUsed) { + if ($now - $lastUsed > $this->idleTimeoutSeconds) { + $keysToRemove[] = $key; + } + } + + foreach ($keysToRemove as $key) { + unset($this->clients[$key]); + unset($this->clientLastUsedTime[$key]); + $this->logger->info("Cleaned up idle RPC client: {$key}"); + } + } + + /** + * Generate a unique cache key based on endpoint and the resolved transport/TLS mode. + * + * The key mirrors resolveCredentials(): when neither tlsCredentials nor a + * pre-created credentials option is present, the default TLS configuration + * and sslEnabled=false must produce different keys so a TLS client can never + * silently reuse a plaintext channel (or vice versa). + * + * Key format: "{endpoint}:{tlsFingerprint}" + * Examples: + * - "localhost:8080:tls|default" (no options, SSL on by default) + * - "localhost:8080:insecure" (sslEnabled=false or insecure TlsCredentials) + * - "localhost:8080:tls|ca:/path/to/ca.pem" + * - "localhost:8080:mtls:/path/to/client.pem|no-verify" + * + * @param string $endpoints Server endpoint + * @param array $options Client options containing TLS credentials + * @return string Unique cache key + */ + private function makeKey(string $endpoints, array $options): string + { + if (isset($options['tlsCredentials']) && $options['tlsCredentials'] instanceof TlsCredentials) { + $tls = $options['tlsCredentials']; + $parts = []; + $parts[] = $tls->isInsecure() ? 'insecure' : 'tls'; + if ($tls->getCaCertPath() !== null) { + $parts[] = 'ca:' . $tls->getCaCertPath(); + } + if ($tls->getClientCertPath() !== null) { + $parts[] = 'mtls:' . $tls->getClientCertPath(); + } + if (!$tls->shouldVerifyPeer()) { + $parts[] = 'no-verify'; + } + $tlsFingerprint = implode('|', $parts); + } elseif (isset($options['credentials'])) { + $tlsFingerprint = 'secure'; + } else { + // Mirror resolveCredentials(): default TLS unless sslEnabled=false + $sslEnabled = $options['sslEnabled'] ?? true; + $tlsFingerprint = $sslEnabled ? 'tls|default' : 'insecure'; + } + return $endpoints . ':' . $tlsFingerprint; + } + + /** + * Resolve gRPC channel credentials from options. + * + * Priority: + * 1. Explicit tlsCredentials option (TlsCredentials instance) + * 2. Explicit credentials option (pre-created ChannelCredentials) + * 3. sslEnabled=false → TlsCredentials::createInsecure() (plaintext, for dev/CI) + * 4. Default: TlsCredentials::createDefault() for secure connection + * + * SECURITY NOTE: SSL is enabled by default to prevent accidental plaintext + * connections in production. Set sslEnabled=false explicitly only for + * development/testing/CI. + * + * @param array $options Configuration options + * @return \Grpc\ChannelCredentials|null Resolved credentials + */ + private function resolveCredentials(array $options) + { + if (isset($options['tlsCredentials']) && $options['tlsCredentials'] instanceof TlsCredentials) { + return $options['tlsCredentials']->toChannelCredentials(); + } + + if (isset($options['credentials'])) { + return $options['credentials']; + } + + $sslEnabled = $options['sslEnabled'] ?? true; + + if (!$sslEnabled) { + $this->logger->debug("SSL disabled, using insecure (plaintext) connection"); + return TlsCredentials::createInsecure()->toChannelCredentials(); + } + + $this->logger->debug("Using default TLS configuration"); + return TlsCredentials::createDefault()->toChannelCredentials(); + } +} diff --git a/php/SendMessageHandler.php b/php/SendMessageHandler.php new file mode 100644 index 000000000..7f656b50b --- /dev/null +++ b/php/SendMessageHandler.php @@ -0,0 +1,702 @@ +logger = Logger::getInstance('Producer'); + } + + // ==================== Send ==================== + + /** + * Send a single message with retry. + * + * @param Message $message The message to send + * @return array{messageId: string, transactionId: string, recallHandle: string, code: int, message: string} + * @throws \RuntimeException If no queue is available or all retries fail + */ + public function send(Message $message): array + { + $this->validator->validateMessage($message); + + $topic = $message->getTopic()->getName(); + $loadBalancer = $this->routeManager->getPublishingLoadBalancer($topic); + + $sysProps = $message->getSystemProperties(); + $hasMessageGroup = $sysProps !== null && $sysProps->hasMessageGroup(); + if ($hasMessageGroup) { + $messageQueue = $loadBalancer->takeMessageQueueByMessageGroup($sysProps->getMessageGroup()); + if (!$messageQueue) { + throw new \RuntimeException( + "No available message queue for message group: {$sysProps->getMessageGroup()}" + ); + } + $candidates = [$messageQueue]; + } else { + $candidates = $loadBalancer->takeMessageQueue( + $this->routeManager->getIsolatedBrokerNames(), + $this->settings->getMaxAttempts() + ); + if (empty($candidates)) { + throw new \RuntimeException("No available message queue for topic: {$topic}"); + } + } + + if ($this->validator->isValidateMessageType()) { + $msgType = $this->validator->detectMessageType($message, false); + $loadBalancer->validateMessageTypeAgainstQueue($candidates[0], $msgType, $topic); + } + + $request = $this->wrapSendMessageRequest([$message], $candidates[0]); + return $this->sendMessageWithRetry($request, $message, $candidates, $this->settings->getMaxAttempts()); + } + + /** + * Send a message asynchronously (Swoole coroutine or Generator fallback). + * + * @param Message $message The message to send + * @return array|\Generator + */ + public function sendAsync(Message $message): array|\Generator + { + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + $channel = new \Swoole\Coroutine\Channel(1); + \Swoole\Coroutine::create(function () use ($message, $channel) { + try { + $result = $this->send($message); + $channel->push(['success' => true, 'result' => $result]); + } catch (\Throwable $e) { + $channel->push(['success' => false, 'exception' => $e]); + } + }); + $data = $channel->pop($this->settings->getRequestTimeout() / 1000.0); + if ($data === false) { + throw new \RuntimeException( + "Send async Request timeout {$this->settings->getRequestTimeout()}ms" + ); + } + if (isset($data['exception'])) { + throw $data['exception']; + } + return $data['result'] ?? null; + } + return $this->sendSyncFallback($message); + } + + // ==================== Batch Send ==================== + + /** + * Send a batch of messages. + * + * All messages must share the same topic. If any message has a messageGroup (FIFO), + * all must belong to the same group. Message types must be uniform. + * + * @param array $messages Messages to send + * @return array + * @throws \InvalidArgumentException If batch is empty, topics differ, or types/groups conflict + * @throws \RuntimeException If no queue is available or all retries fail + */ + public function sendBatch(array $messages): array + { + if (empty($messages)) { + throw new \InvalidArgumentException("Batch messages cannot be empty"); + } + + $topic = $messages[0]->getTopic()->getName(); + $messageTypes = []; + $messageGroups = []; + $hasFifoMessage = false; + foreach ($messages as $msg) { + if ($msg->getTopic()->getName() !== $topic) { + throw new \InvalidArgumentException("All messages in a batch must have the same topic"); + } + $this->validator->validateMessage($msg); + if ($this->validator->isValidateMessageType()) { + $messageTypes[] = $this->validator->detectMessageType($msg, false); + } + $sysProps = $msg->getSystemProperties(); + if ($sysProps !== null && $sysProps->hasMessageGroup()) { + $hasFifoMessage = true; + $messageGroups[] = $sysProps->getMessageGroup(); + } + } + if ($this->validator->isValidateMessageType() && count(array_unique($messageTypes)) > 1) { + throw new \InvalidArgumentException('Messages to send different message types , please check'); + } + if ($hasFifoMessage && count(array_unique($messageGroups)) > 1) { + throw new \InvalidArgumentException("FIFO messages to send have different message groups, please check"); + } + + $loadBalancer = $this->routeManager->getPublishingLoadBalancer($topic); + $isolatedBroker = $this->routeManager->getIsolatedBrokerNames(); + + if ($hasFifoMessage) { + $messageGroup = $messageGroups[0]; + $mq = $loadBalancer->takeMessageQueueByMessageGroup($messageGroup); + $messageQueue = $mq !== null ? [$mq] : []; + } else { + $messageQueue = $loadBalancer->takeMessageQueue($isolatedBroker, $this->settings->getMaxAttempts()); + } + if (empty($messageQueue)) { + throw new \RuntimeException("No available message queue for topic: {$topic}"); + } + + $request = $this->wrapSendMessageRequest($messages, $messageQueue[0]); + return $this->sendBatchWithRetry($request, $messages, $messageQueue, $this->settings->getMaxAttempts()); + } + + /** + * Send a batch of messages asynchronously (Swoole coroutine or Generator fallback). + * + * @param array $messages Messages to send + * @return array|\Generator + */ + public function sendBatchAsync(array $messages): array|\Generator + { + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + $channel = new \Swoole\Coroutine\Channel(1); + \Swoole\Coroutine::create(function () use ($messages, $channel) { + try { + $result = $this->sendBatch($messages); + $channel->push(['success' => true, 'result' => $result]); + } catch (\Throwable $e) { + $channel->push(['success' => false, 'exception' => $e]); + } + }); + $data = $channel->pop($this->settings->getRequestTimeout() / 1000.0); + if ($data === false) { + throw new \RuntimeException( + "Send batch async Request timeout {$this->settings->getRequestTimeout()}ms" + ); + } + if (isset($data['exception'])) { + throw $data['exception']; + } + return $data['result'] ?? null; + } + return $this->sendBatchSyncFallback($messages); + } + + // ==================== Convenience Builders ==================== + + /** + * Build a message with custom system properties (used by convenience send methods). + * + * @param string $topic Topic name + * @param string $body Message body + * @param string $tag Optional message tag + * @param callable $configurator fn(SystemProperties): void to set priority/group/delay + * @return Message + */ + public function buildConvenienceMessage(string $topic, string $body, string $tag, callable $configurator): Message + { + $topicResource = new Resource(); + $topicResource->setName($topic); + + $sysProps = new SystemProperties(); + if (!empty($tag)) { + $sysProps->setTag($tag); + } + $configurator($sysProps); + + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody($body); + $message->setSystemProperties($sysProps); + + return $message; + } + + // ==================== Message Building ==================== + + /** + * Detect message type via MessageValidator. + * + * @param Message $msg + * @param bool $txEnabled Whether to consider TRANSACTION type + * @return int MessageType constant + */ + public function detectMessageType(Message $msg, bool $txEnabled = false): int + { + return $this->validator->detectMessageType($msg, $txEnabled); + } + + private function createTimestamp(): Timestamp + { + $now = microtime(true); + $timestamp = new Timestamp(); + $timestamp->setSeconds((int)$now); + $timestamp->setNanos((int)(($now - (int)$now) * 1000000000)); + return $timestamp; + } + + /** + * Convert a user-facing Message into a fully enriched protobuf Message for sending. + * + * Assigns messageId, bornTimestamp, bornHost, encoding, queueId, messageType, + * and copies over optional fields (tag, keys, messageGroup, deliveryTimestamp, + * liteTopic, priority, traceContext) from the input message. + */ + private function toProtobufMessage(Message $msg, object $messageQueue, bool $txEnabled = false): Message + { + $messageId = MessageIdCodec::getInstance()->nextMessageId()->toString(); + + $systemProperties = new SystemProperties(); + $systemProperties->setMessageId($messageId); + $systemProperties->setBornTimestamp($this->createTimestamp()); + $systemProperties->setBornHost(gethostname() ?: 'localhost'); + + // Preserve encoding from input message; default to IDENTITY + $inputSysProps = $msg->getSystemProperties(); + $encoding = Encoding::IDENTITY; + if ($inputSysProps !== null) { + $inputEncoding = $inputSysProps->getBodyEncoding(); + if ($inputEncoding !== Encoding::ENCODING_UNSPECIFIED) { + $encoding = $inputEncoding; + } + } + $systemProperties->setBodyEncoding($encoding); + $queueId = $messageQueue->getId(); + if ($queueId !== null) { + $systemProperties->setQueueId($queueId); + } + $systemProperties->setMessageType($this->detectMessageType($msg, $txEnabled)); + + if ($inputSysProps) { + if ($inputSysProps->hasTag()) { + $systemProperties->setTag($inputSysProps->getTag()); + } + if (!ProtobufUtil::isRepeatedFieldEmpty($inputSysProps->getKeys())) { + $systemProperties->setKeys($inputSysProps->getKeys()); + } + if ($inputSysProps->hasMessageGroup()) { + $systemProperties->setMessageGroup($inputSysProps->getMessageGroup()); + } + if ($inputSysProps->hasDeliveryTimestamp()) { + $systemProperties->setDeliveryTimestamp($inputSysProps->getDeliveryTimestamp()); + } + if ($inputSysProps->hasLiteTopic()) { + $systemProperties->setLiteTopic($inputSysProps->getLiteTopic()); + } + if ($inputSysProps->hasPriority()) { + $systemProperties->setPriority($inputSysProps->getPriority()); + } + if ($inputSysProps->hasTraceContext()) { + $systemProperties->setTraceContext($inputSysProps->getTraceContext()); + } + } + + $topicResource = new Resource(); + $topicResource->setName($msg->getTopic()->getName()); + + $protoMsg = new Message(); + $protoMsg->setTopic($topicResource); + $protoMsg->setBody($msg->getBody()); + $protoMsg->setSystemProperties($systemProperties); + + $userProps = $msg->getUserProperties(); + if (!ProtobufUtil::isMapFieldEmpty($userProps)) { + foreach ($userProps as $key => $value) { + $protoMsg->getUserProperties()[$key] = $value; + } + } + + return $protoMsg; + } + + /** + * Wrap messages into a SendMessageRequest (non-transaction). + */ + public function wrapSendMessageRequest(array $messages, object $messageQueue): SendMessageRequest + { + $enriched = []; + foreach ($messages as $msg) { + $enriched[] = $this->toProtobufMessage($msg, $messageQueue); + } + $request = new SendMessageRequest(); + $request->setMessages($enriched); + return $request; + } + + /** + * Wrap messages into a SendMessageRequest with transaction message type. + * + * Used by TransactionTrait for half-message sending. + */ + public function wrapTransactionMessageRequest(array $messages, object $messageQueue): SendMessageRequest + { + $enriched = []; + foreach ($messages as $msg) { + $enriched[] = $this->toProtobufMessage($msg, $messageQueue, true); + } + $request = new SendMessageRequest(); + $request->setMessages($enriched); + return $request; + } + + // ==================== Retry Logic ==================== + + /** + * Send a single message with retry, deadline, and queue rotation. + * + * On each failed attempt, the failed broker endpoint is isolated and the + * next candidate queue is tried. Retry delay follows the configured + * ExponentialBackoffRetryPolicy with jitter. + * + * @param SendMessageRequest $request The gRPC request + * @param Message $message The original user message (for interceptor context) + * @param array $candidates Candidate message queues for rotation + * @param int $maxAttempts Maximum number of attempts + * @param bool $txEnabled Whether the request carries a transaction (half) message; + * preserved when the request is rebuilt for a retry + * @return array{messageId: string, transactionId: string, recallHandle: string, code: int, message: string, endpoints: ?object} + * @throws \RuntimeException If deadline exceeded or all attempts fail + */ + public function sendMessageWithRetry( + SendMessageRequest $request, + Message $message, + array $candidates, + int $maxAttempts, + bool $txEnabled = false + ): array { + $lastException = null; + $startTime = microtime(true); + $candidateCount = count($candidates); + $currentMessageQueue = $candidates[0]; + + $operationTimeout = ($this->operationTimeoutFn)('SEND_MESSAGE'); + $deadlineMicroseconds = $startTime + ($operationTimeout / 1000000); + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $now = microtime(true); + if ($now >= $deadlineMicroseconds) { + throw new \RuntimeException( + "Send message deadline exceeded after " . + round(($now - $startTime) * 1000, 2) . "ms" + ); + } + + if ($attempt > 1 && $candidateCount > 1) { + $queueIndex = IntMath::mod($attempt, $candidateCount); + $currentMessageQueue = $candidates[$queueIndex]; + // Rebuild with the original message type: a transaction (half) message + // must not be retried as a normal, immediately visible message + $request = $txEnabled + ? $this->wrapTransactionMessageRequest([$message], $currentMessageQueue) + : $this->wrapSendMessageRequest([$message], $currentMessageQueue); + } + try { + $remainingTimeUs = max(1000000, ($deadlineMicroseconds - microtime(true)) * 1000000); + $remainingTimeMs = (int)($remainingTimeUs / 1000); + $metadata = ($this->metadataBuilder)($remainingTimeMs); + $callOptions = ['timeout' => min($remainingTimeUs, ClientConstants::GRPC_SEND_MESSAGE_TIMEOUT)]; + + list($response, $status) = $this->client->SendMessage( + $request, $metadata, $callOptions + )->wait(); + + if ($status->code !== 0) { + throw new \RuntimeException("Send message failed: " . $status->details); + } + + $entries = $response->getEntries() + ? ProtobufUtil::repeatedFieldToArray($response->getEntries()) + : []; + + if ($response->hasStatus()) { + $respStatus = $response->getStatus(); + if ($respStatus->getCode() !== 20000) { + throw new \RuntimeException( + "SendMessage failed with code: " . $respStatus->getCode() . + ", message: " . $respStatus->getMessage() + ); + } + } + + if (count($entries) > 0) { + $entry = $entries[0]; + $resultStatus = $entry->getStatus(); + + if ($resultStatus->getCode() !== 20000) { + throw new \RuntimeException( + "Send message failed with code: " . $resultStatus->getCode() + ); + } + + $latencyMs = (microtime(true) - $startTime) * 1000; + ($this->interceptorExecutor)(MessageHookPoints::SEND, [ + 'success' => true, + 'latencyMs' => $latencyMs, + 'topic' => $message->getTopic()->getName(), + 'messageType' => $this->detectMessageType($message, $txEnabled), + 'sendReceipts' => [ + 'messageId' => $entry->getMessageId(), + 'transactionId' => $entry->getTransactionId(), + ] + ]); + + return [ + 'messageId' => $entry->getMessageId(), + 'transactionId' => $entry->getTransactionId(), + 'recallHandle' => $entry->getRecallHandle() ?? '', + 'code' => $resultStatus->getCode(), + 'message' => $resultStatus->getMessage(), + // Endpoint of the queue that actually succeeded (may differ from + // $candidates[0] after retries); used for transaction tracking + 'endpoints' => PublishingRouteManager::extractMessageQueueEndpoint($currentMessageQueue), + ]; + } + + throw new \RuntimeException("No response entries"); + + } catch (\Exception $e) { + $lastException = $e; + $this->logger->error("Send attempt {$attempt} failed: " . $e->getMessage()); + + $failedEndpoints = PublishingRouteManager::extractMessageQueueEndpoint($currentMessageQueue); + if ($failedEndpoints !== null) { + $this->routeManager->isolateEndpoints($failedEndpoints); + } + + if ($attempt < $maxAttempts) { + $delayMs = $this->settings->getRetryPolicy()->getNextDelayWithJitterMs($attempt); + if ($delayMs > 0) { + SwooleCompat::sleep($delayMs * 1000); + } + } + } + } + + $latencyMs = (microtime(true) - $startTime) * 1000; + ($this->interceptorExecutor)(MessageHookPoints::SEND, [ + 'success' => false, + 'latencyMs' => $latencyMs, + 'topic' => $message->getTopic()->getName(), + 'messageType' => $this->detectMessageType($message, $txEnabled), + 'sendException' => $lastException ? $lastException->getMessage() : '', + ]); + throw $lastException; + } + + /** + * Send a batch of messages with retry, deadline, and queue rotation. + * + * Verifies that the response entry count matches the request message count. + * Any non-OK entry triggers a retry of the entire batch. + * + * @param SendMessageRequest $request The gRPC request + * @param array $messages The original messages (for interceptor context) + * @param array $candidates Candidate message queues for rotation + * @param int $maxAttempts Maximum number of attempts + * @return array + * @throws \RuntimeException If deadline exceeded or all attempts fail + */ + public function sendBatchWithRetry( + SendMessageRequest $request, + array $messages, + array $candidates, + int $maxAttempts + ): array { + $lastException = null; + $startTime = microtime(true); + $topic = $messages[0]->getTopic()->getName(); + $candidateCount = count($candidates); + $currentMessageQueue = $candidates[0]; + + $operationTimeout = ($this->operationTimeoutFn)('SEND_MESSAGE'); + $deadlineMicroseconds = $startTime + ($operationTimeout / 1000000); + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $now = microtime(true); + if ($now >= $deadlineMicroseconds) { + throw new \RuntimeException( + "Batch send deadline exceeded after " . + round(($now - $startTime) * 1000, 2) . "ms" + ); + } + + if ($attempt > 1 && $candidateCount > 1) { + $queueIndex = IntMath::mod($attempt, $candidateCount); + $currentMessageQueue = $candidates[$queueIndex]; + $request = $this->wrapSendMessageRequest($messages, $currentMessageQueue); + } + try { + $remainingTimeUs = max(1000000, ($deadlineMicroseconds - microtime(true)) * 1000000); + $remainingTimeMs = (int)($remainingTimeUs / 1000); + $metadata = ($this->metadataBuilder)($remainingTimeMs); + $callOptions = ['timeout' => min($remainingTimeUs, ClientConstants::GRPC_SEND_MESSAGE_TIMEOUT)]; + + list($response, $status) = $this->client->SendMessage( + $request, $metadata, $callOptions + )->wait(); + + if ($status->code !== 0) { + throw new \RuntimeException("Batch send failed: " . $status->details); + } + + $entries = $response->getEntries() + ? ProtobufUtil::repeatedFieldToArray($response->getEntries()) + : []; + + if ($response->hasStatus()) { + $respStatus = $response->getStatus(); + if ($respStatus->getCode() !== 20000) { + throw new \RuntimeException( + "Batch send failed with code: " . $respStatus->getCode() . + ", message: " . $respStatus->getMessage() + ); + } + } + + // Verify response entry count matches request message count + $entryCount = count($entries); + $messageCount = count($messages); + if ($entryCount !== $messageCount) { + throw new \RuntimeException( + "Batch response entry count ({$entryCount}) does not match " . + "request message count ({$messageCount})" + ); + } + + // Fail the batch on any non-OK entry to trigger retry + $results = []; + foreach ($entries as $i => $entry) { + $entryStatus = $entry->getStatus(); + $code = $entryStatus ? $entryStatus->getCode() : 0; + $msg = $entryStatus ? $entryStatus->getMessage() : 'No status'; + + if ($code !== 20000) { + throw new \RuntimeException( + "Batch entry {$i} failed with code: {$code}, message: {$msg}" + ); + } + + $results[] = [ + 'messageId' => $entry->getMessageId(), + 'transactionId' => $entry->getTransactionId() ?? '', + 'recallHandle' => $entry->getRecallHandle() ?? '', + 'code' => $code, + 'message' => $msg, + ]; + } + + $latencyMs = (microtime(true) - $startTime) * 1000; + ($this->interceptorExecutor)(MessageHookPoints::SEND, [ + 'success' => true, + 'latencyMs' => $latencyMs, + 'topic' => $topic, + ]); + + return $results; + + } catch (\Exception $e) { + $lastException = $e; + $this->logger->error("Batch send attempt {$attempt} failed: " . $e->getMessage()); + + $failedEndpoints = PublishingRouteManager::extractMessageQueueEndpoint($currentMessageQueue); + if ($failedEndpoints !== null) { + $this->routeManager->isolateEndpoints($failedEndpoints); + } + + if ($attempt < $maxAttempts) { + $delayMs = $this->settings->getRetryPolicy()->getNextDelayWithJitterMs($attempt); + if ($delayMs > 0) { + SwooleCompat::sleep($delayMs * 1000); + } + } + } + } + + $latencyMs = (microtime(true) - $startTime) * 1000; + ($this->interceptorExecutor)(MessageHookPoints::SEND, [ + 'success' => false, + 'latencyMs' => $latencyMs, + 'topic' => $topic, + ]); + throw $lastException; + } + + // ==================== Sync Fallbacks ==================== + + /** + * Generator fallback for sendAsync when Swoole is not available. + * + * @param Message $message + * @return \Generator + */ + private function sendSyncFallback(Message $message): \Generator + { + yield $this->send($message); + } + + /** + * Generator fallback for sendBatchAsync when Swoole is not available. + * + * @param array $messages + * @return \Generator + */ + private function sendBatchSyncFallback(array $messages): \Generator + { + yield $this->sendBatch($messages); + } +} diff --git a/php/SessionCredentials.php b/php/SessionCredentials.php new file mode 100644 index 000000000..2e25aec07 --- /dev/null +++ b/php/SessionCredentials.php @@ -0,0 +1,80 @@ +accessKey = $accessKey; + $this->accessSecret = $accessSecret; + $this->securityToken = $securityToken; + } + + /** + * Get the access key. + * + * @return string The access key + */ + public function getAccessKey(): string + { + return $this->accessKey; + } + + /** + * Get the access secret. + * + * @return string The access secret + */ + public function getAccessSecret(): string + { + return $this->accessSecret; + } + + /** + * Get the optional STS security token. + * + * @return string|null The security token, or null if not set + */ + public function getSecurityToken(): ?string + { + return $this->securityToken; + } +} diff --git a/php/Signature.php b/php/Signature.php new file mode 100644 index 000000000..c512f3b55 --- /dev/null +++ b/php/Signature.php @@ -0,0 +1,126 @@ + uppercase hex digest + * 4. Build authorization header: + * "MQv2-HMAC-SHA1 Credential=, SignedHeaders=x-mq-date-time, Signature=" + */ +class Signature +{ + private const ALGORITHM = 'MQv2-HMAC-SHA1'; + private const CREDENTIAL = 'Credential'; + private const SIGNED_HEADERS = 'SignedHeaders'; + private const SIGNATURE = 'Signature'; + private const DATE_TIME_FORMAT = 'Ymd\THis\Z'; + private const SIGNED_HEADERS_VALUE = 'x-mq-date-time'; + + /** + * Generate signed gRPC metadata with MQv2-HMAC-SHA1 authorization. + * + * @param SessionCredentials|null $credentials Session credentials or null for unsigned metadata + * @param string $clientId Client identifier + * @param string $language Language string (e.g., "PHP") + * @param string $clientVersion Client version string (e.g., "5.0.0") + * @param string $namespace Namespace string + * @param string $protocol Protocol version (e.g., "v2") + * @return array gRPC metadata array + * @throws \Exception If random_int() fails to gather sufficient randomness + */ + public static function sign( + ?SessionCredentials $credentials, + string $clientId, + string $language = 'PHP', + string $clientVersion = '5.0.0', + string $namespace = '', + string $protocol = 'v2' + ): array { + $dateTime = gmdate(self::DATE_TIME_FORMAT); + $requestId = self::generateUUID(); + + $metadata = [ + 'x-mq-client-id' => [$clientId], + 'x-mq-language' => [$language], + 'x-mq-client-version' => [$clientVersion], + 'x-mq-protocol' => [$protocol], + 'x-mq-date-time' => [$dateTime], + 'x-mq-request-id' => [$requestId], + 'x-mq-namespace' => [$namespace], + ]; + + if ($credentials !== null) { + // Add STS security token if present + $securityToken = $credentials->getSecurityToken(); + if (!empty($securityToken)) { + $metadata['x-mq-session-token'] = [$securityToken]; + } + + // Compute HMAC-SHA1 signature + $accessSecret = $credentials->getAccessSecret(); + $accessKey = $credentials->getAccessKey(); + $signature = self::hmacSha1($accessSecret, $dateTime); + + // Build authorization header value + $authValue = self::ALGORITHM + . ' ' . self::CREDENTIAL . '=' . $accessKey + . ', ' . self::SIGNED_HEADERS . '=' . self::SIGNED_HEADERS_VALUE + . ', ' . self::SIGNATURE . '=' . $signature; + + $metadata['authorization'] = [$authValue]; + } + + return $metadata; + } + + /** + * Compute HMAC-SHA1 and return uppercase hex digest. + * + * @param string $key Secret key for HMAC + * @param string $data Data to sign + * @return string Uppercase hex-encoded HMAC-SHA1 digest + */ + private static function hmacSha1(string $key, string $data): string + { + return strtoupper(hash_hmac('sha1', $data, $key)); + } + + /** + * Generate a UUID v4 string for request-id. + * + * @return string UUID v4 string + * @throws \Exception If random_int() fails to gather sufficient randomness + */ + private static function generateUUID(): string + { + return sprintf( + '%08x-%04x-%04x-%04x-%012x', + random_int(0, 0xffffffff), + random_int(0, 0xffff), + random_int(0, 0xffff) & 0x0fff | 0x4000, + random_int(0, 0x3fff) | 0x8000, + random_int(0, 0xffffffffffff) + ); + } +} diff --git a/php/SimpleConsumer.php b/php/SimpleConsumer.php new file mode 100644 index 000000000..632b1d209 --- /dev/null +++ b/php/SimpleConsumer.php @@ -0,0 +1,1531 @@ +, pre-populated subscriptions (topic => expression) + */ + public function __construct( + private readonly string $endpoints, + private readonly string $consumerGroup, + array $options = [] + ) { + $this->clientId = $options['clientId'] ?? ('php-consumer-' . getmypid() . '-' . time()); + $this->namespace = $options['namespace'] ?? ''; + $this->requestTimeout = $options['requestTimeout'] ?? 3000; + $this->awaitDuration = $options['awaitDuration'] ?? 30; + $this->tlsCredentials = $options['tlsCredentials'] ?? null; + $this->sslEnabled = $options['sslEnabled'] ?? true; + + // Pre-populate subscriptions from options, since subscribe() requires isStarted=true + // and start() requires non-empty subscriptions — breaking the cyclic dependency. + if (isset($options['subscriptionExpressions']) && is_array($options['subscriptionExpressions'])) { + $this->subscriptions = $options['subscriptionExpressions']; + } + + // Set AK/SK credentials if provided + if (isset($options['credentials']) && $options['credentials'] instanceof SessionCredentials) { + $this->credentials = $options['credentials']; + } + + // Create gRPC client via connection pool + $this->client = RpcClientManager::getInstance()->getClient($endpoints, [ + 'tlsCredentials' => $this->tlsCredentials, + 'sslEnabled' => $this->sslEnabled, + ]); + + // Initialize Telemetry Session (singleton) + $this->telemetrySession = TelemetrySession::getInstance($this->client, $endpoints, $this->clientId, $this->credentials, $this->namespace); + $this->logger = Logger::getInstance('SimpleConsumer'); + } + + /** + * Subscribe to a topic + * + * @param string $topic Topic name + * @param string $expression Filter expression (default "*") + * @return $this + */ + public function subscribe(string $topic, string $expression = '*'): self + { + if (!$this->isStarted) { + throw new \RuntimeException("Consumer is not started"); + } + + $this->subscriptions[$topic] = $expression; + return $this; + } + + /** + * Unsubscribe from a topic + * + * @param string $topic Topic name + * @return $this + */ + public function unsubscribe(string $topic): self + { + if (!$this->isStarted) { + throw new \RuntimeException("Consumer is not started"); + } + + unset($this->subscriptions[$topic]); + return $this; + } + + /** + * Get all subscription expressions. + * + * @return array Map of topic to filter expression + */ + public function getSubscriptionExpressions(): array + { + return $this->subscriptions; + } + + /** + * Start the consumer + * + * @return void + * @throws \RuntimeException If no subscriptions configured or Telemetry Session fails + */ + public function start(): void + { + if ($this->isStarted) { + return; + } + + if (empty($this->subscriptions)) { + throw new \RuntimeException("No subscriptions configured"); + } + + // Establish Telemetry Session + $this->establishTelemetrySession(); + + $this->isStarted = true; + + // Start periodic heartbeat via SIGALRM timer + $this->startHeartbeat(); + } + + /** + * Heartbeat tick handler - call from main loop to keep connection alive. + * Sends heartbeat every 10 seconds to the broker. + * + * @return void + */ + public function onHeartbeatTick(): void + { + $now = time(); + if ($now - $this->lastHeartbeatTime >= 10) { + // Check concurrency guard before sending heartbeat + if ($this->heartbeatInProgress) { + $this->logger->debug("Heartbeat already in progress, skipping this tick"); + return; + } + + $this->heartbeatInProgress = true; + try { + $this->doHeartbeat(); + $this->lastHeartbeatTime = $now; + } catch (\Throwable $e) { + $this->logger->warning("Heartbeat tick failed: " . $e->getMessage()); + } finally { + $this->heartbeatInProgress = false; + } + } + } + + /** + * Send heartbeat to the broker to keep connection alive. + * + * @return void + */ + public function doHeartbeat(): void + { + $request = new HeartbeatRequest(); + $request->setClientType(ClientType::SIMPLE_CONSUMER); + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + $request->setGroup($groupResource); + + $metadata = $this->buildMetadata($this->requestTimeout); + + try { + list($response, $status) = $this->client->Heartbeat($request, $metadata, $this->getCallOptions())->wait(); + if ($status->code === 0) { + $this->logger->debug("Heartbeat sent successfully"); + } else { + $this->logger->warning("Heartbeat failed: " . $status->details); + } + } catch (\Exception $e) { + $this->logger->warning("Heartbeat failed: " . $e->getMessage()); + } + } + + /** + * Establish Telemetry Session with the broker. + * + * Sends settings command synchronously to register subscriptions. + * + * @return void + * @throws \RuntimeException If settings sync fails + */ + private function establishTelemetrySession(): void + { + // Create UserAgent + $ua = new UA(); + $ua->setLanguage(Language::PHP); + $ua->setVersion(ClientConstants::CLIENT_VERSION); + + // Create SubscriptionEntry list + $subscriptionEntries = []; + foreach ($this->subscriptions as $topic => $expression) { + $filterExpression = new FilterExpression(); + $filterExpression->setExpression($expression); + $filterExpression->setType(\Apache\Rocketmq\V2\FilterType::TAG); + + $topicResource = new Resource(); + $topicResource->setName($topic); + + $subscriptionEntry = new SubscriptionEntry(); + $subscriptionEntry->setTopic($topicResource); + $subscriptionEntry->setExpression($filterExpression); + + $subscriptionEntries[] = $subscriptionEntry; + } + + // Create Subscription configuration + $subscription = new Subscription(); + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + $subscription->setGroup($groupResource); + $subscription->setSubscriptions($subscriptionEntries); + + // Create Settings + $settings = new Settings(); + $settings->setClientType(ClientType::SIMPLE_CONSUMER); + $settings->setUserAgent($ua); + $settings->setSubscription($subscription); + + // Create TelemetryCommand + $command = new TelemetryCommand(); + $command->setSettings($settings); + + // Send Settings synchronously + $success = $this->telemetrySession->syncSettings($command); + + if (!$success) { + throw new \RuntimeException("Failed to establish Telemetry Session"); + } + + // Wait for server processing + SwooleCompat::sleep(500000); // 500ms + } + + /** + * Register a message interceptor. + * + * @param MessageInterceptor $interceptor + * @return $this + */ + public function addInterceptor(MessageInterceptor $interceptor): self + { + $this->interceptors[] = $interceptor; + return $this; + } + + /** + * Execute interceptors at a given hook point. + * + * @param string $hookPoint One of MessageHookPoints constants + * @param array $context Context data for the hook point + * @return void + */ + public function executeInterceptors(string $hookPoint, array $context = []): void + { + if (empty($this->interceptors)) { + return; + } + foreach ($this->interceptors as $interceptor) { + try { + $interceptor->intercept($hookPoint, $context); + } catch (\Exception $e) { + $this->logger->warning("Interceptor failed at {$hookPoint}: " . $e->getMessage()); + } + } + } + + /** + * Receive messages from all queues of subscribed topics. + * Iterates through ALL queues of each topic (not just one round-robin queue) + * with short per-queue timeout, so the full cycle completes quickly. + * + * @param int $maxMessages Maximum number of messages to receive in total + * @param int $invisibleDuration Invisible duration in seconds for received messages + * @return array List of received MessageView objects + * @throws \RuntimeException If consumer not started + * @throws \InvalidArgumentException If maxMessages <= 0 + */ + public function receive(int $maxMessages = 10, int $invisibleDuration = 30): array + { + if (!$this->isStarted) { + throw new \RuntimeException("Consumer not started"); + } + + if ($maxMessages <= 0) { + throw new \InvalidArgumentException("Invalid maxMessages must be greater than 0"); + } + + $startTime = microtime(true); + + // Receive from all queues + $allMessages = $this->receiveFromAllQueues($maxMessages, $invisibleDuration); + + $latencyMs = (microtime(true) - $startTime) * 1000; + $this->executeInterceptors(MessageHookPoints::RECEIVE, [ + 'success' => true, + 'latencyMs' => $latencyMs, + 'messageCount' => count($allMessages), + ]); + + // Periodic heartbeat with concurrency guard + $now = time(); + if ($now - $this->lastHeartbeatTime >= 10) { + if (!$this->heartbeatInProgress) { + $this->heartbeatInProgress = true; + try { + $this->doHeartbeat(); + $this->lastHeartbeatTime = $now; + } catch (\Throwable $e) { + $this->logger->warning("Receive-triggered heartbeat failed: " . $e->getMessage()); + } finally { + $this->heartbeatInProgress = false; + } + } + } + + if (isset($this->telemetrySession)) { + try { + $this->telemetrySession->pollTelemetry(); + } catch (\Throwable $e) { + $this->logger->debug("Telemetry poll failed: " . $e->getMessage()); + } + } + return $allMessages; + } + + /** + * Receive messages from all queues of all subscribed topics. + * Iterates through ALL queues with short per-queue timeout. + * + * @param int $maxMessages Maximum number of messages to receive in total + * @param int $invisibleDuration Invisible duration in seconds for received messages + * @return array List of received Message objects + */ + private function receiveFromAllQueues(int $maxMessages, int $invisibleDuration): array + { + $allMessages = []; + $topics = array_keys($this->subscriptions); + + if (empty($topics)) { + return []; + } + + // Count total queues across all topics + $totalQueues = 0; + $topicInfo = []; + + foreach ($topics as $topic) { + $expression = $this->subscriptions[$topic]; + $loadBalancer = $this->getSubscriptionLoadBalancer($topic); + $queues = $loadBalancer->getMessageQueues(); + $topicInfo[$topic] = [ + 'expression' => $expression, + 'queues' => $queues, + 'loadBalancer' => $loadBalancer, + ]; + $totalQueues += count($queues); + } + + if ($totalQueues === 0) { + return []; + } + + // Short per-queue timeout: distribute awaitDuration across all queues + // This ensures the full cycle completes quickly + $avgQueueOverhead = 2; + $targetCallTime = 30; + $perQueueTimeout = max(2, (int)($this->awaitDuration / max(1, $totalQueues))); + $perQueueTimeout = min($perQueueTimeout, 5); + $batchSize = (int)ceil($targetCallTime / $avgQueueOverhead); + $batchSize = max(4, min($batchSize, $totalQueues)); + $batchSize = min($batchSize, 32); + $selectedQueues = []; + $seenKeys = []; + foreach ($topicInfo as $topic => $info) { + $lb = $info['loadBalancer']; + $topicQueueCount = count($info['queues']); + $attempts = 0; + while (count($selectedQueues) < $batchSize && $attempts < $topicQueueCount * 2) { + $attempts++; + $queue = $lb->takeMessageQueue(); + if (!$queue) { + break; + } + $key = spl_object_id($queue); + if (!isset($seenKeys[$key])) { + $seenKeys[$key] = true; + $selectedQueues[] = ['topic' => $topic, 'queue' => $queue]; + } + } + } + + // Poll each selected queue in the batch + $debugBatch = getenv('SC_DEBUG') ? true : false; + $batchResults = []; + foreach ($selectedQueues as $sq) { + if (count($allMessages) >= $maxMessages) { + break; + } + $messages = $this->receiveFromSpecificQueue( + $sq['topic'], + $topicInfo[$sq['topic']]['expression'], + $sq['queue'], + $maxMessages - count($allMessages), + $invisibleDuration, + $perQueueTimeout + ); + if ($debugBatch) { + $broker = $sq['queue']->getBroker(); + $brokerName = $broker ? $broker->getName() : ''; + $batchResults[] = $brokerName . '=' . count($messages); + } + if (!empty($messages)) { + $allMessages = array_merge($allMessages, $messages); + } + if ($debugBatch) { + $totalFound = count($allMessages); + $this->logger->debug("Batch: " . implode(',', $batchResults) . " (found $totalFound)"); + } + } + return $allMessages; + } + + /** + * Receive messages from a specific topic via streaming gRPC call. + * + * @param string $topic Topic name + * @param string $expression Filter expression + * @param int $maxMessages Maximum number of messages to receive + * @param int $invisibleDuration Invisible duration in seconds + * @param int|null $longPollingTimeout Long polling timeout in seconds + * @return array List of received Message objects + */ + private function receiveFromTopic(string $topic, string $expression, int $maxMessages, int $invisibleDuration, ?int $longPollingTimeout = null): array + { + // Query route to get MessageQueue first + $messageQueue = $this->getMessageQueue($topic); + + if (!$messageQueue) { + return []; + } + + return $this->receiveFromSpecificQueue($topic, $expression, $messageQueue, $maxMessages, $invisibleDuration, $longPollingTimeout); + } + + /** + * Receive messages from a specific message queue via streaming gRPC call. + * + * @param string $topic Topic name + * @param string $expression Filter expression + * @param \Apache\Rocketmq\V2\MessageQueue $messageQueue The specific message queue to receive from + * @param int $maxMessages Maximum number of messages to receive + * @param int $invisibleDuration Invisible duration in seconds + * @param int|null $longPollingTimeout Long polling timeout in seconds + * @return array List of received Message objects + */ + private function receiveFromSpecificQueue(string $topic, string $expression, object $messageQueue, int $maxMessages, int $invisibleDuration, ?int $longPollingTimeout = null): array + { + $filterExpression = new FilterExpression(); + $filterExpression->setExpression($expression); + $filterExpression->setType(\Apache\Rocketmq\V2\FilterType::TAG); + + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + + $request = new ReceiveMessageRequest(); + $request->setGroup($groupResource); + $request->setMessageQueue($messageQueue); + $request->setFilterExpression($filterExpression); + $request->setBatchSize($maxMessages); + $request->setAutoRenew(false); + + $invisibleDurationObj = new Duration(); + $invisibleDurationObj->setSeconds($invisibleDuration); + $request->setInvisibleDuration($invisibleDurationObj); + + $effectiveLongPollingTimeout = $longPollingTimeout ?? $this->awaitDuration; + $longPollingTimeoutObj = new Duration(); + $longPollingTimeoutObj->setSeconds($effectiveLongPollingTimeout); + $request->setLongPollingTimeout($longPollingTimeoutObj); + + $attemptId = $this->generateAttemptId(); + $request->setAttemptId($attemptId); + + // Route ReceiveMessage to broker endpoints from MessageQueue + $receiveClient = $this->getBrokerClient($messageQueue); + + // Calculate total gRPC timeout including long polling (convert to milliseconds for buildMetadata) + $grpcTimeoutMs = $this->requestTimeout + $effectiveLongPollingTimeout * 1000; + $grpcTimeoutUs = $grpcTimeoutMs * 1000; + // Use signed metadata via ClientTrait with deadline + $metadata = $this->buildMetadata($grpcTimeoutMs); + + $callOptions = ['timeout' => $grpcTimeoutUs]; + $this->logger->debug("ReceiveMessage: topic={$topic}, batchSize={$maxMessages}, grpcTimeout={$grpcTimeoutMs}ms, attemptId={$attemptId}"); + + // Receive messages - deadline enforced by server + $call = $receiveClient->ReceiveMessage($request, $metadata, $callOptions); + + $messages = []; + try { + foreach ($call->responses() as $response) { + if ($response->hasStatus()) { + $status = $response->getStatus(); + $statusCode = $status->getCode(); + if ($statusCode !== 20000 && $statusCode !== 40404) { + $this->logger->warning("Non-OK status from ReceiveMessage: code={$statusCode}, message=" . $status->getMessage()); + } + } + + if ($response->hasMessage()) { + $messages[] = $response->getMessage(); + } + + // Check if maximum count is reached + if (count($messages) >= $maxMessages) { + break; + } + } + } catch (\Exception $e) { + if (strpos($e->getMessage(), 'DEADLINE_EXCEEDED') === false) { + throw $e; + } + } + + $brokerEndpoint = null; + $broker = $messageQueue->getBroker(); + if ($broker && $broker->hasEndpoints()) { + $brokerEndpoint = $broker->getEndpoints(); + } + $messageViews = []; + foreach ($messages as $msg) { + $messageViews[] = new MessageView($msg, null, $brokerEndpoint); + } + return $messageViews; + } + + /** + * Get MessageQueue via QueryRoute + SubscriptionLoadBalancer. + * SimpleConsumer uses QueryRoute (not QueryAssignment) like Java's SimpleConsumerImpl. + * Round-robin across subscribed topics. + * + * @param string $topic Topic name + * @return \Apache\Rocketmq\V2\MessageQueue|null + */ + private function getMessageQueue(string $topic): ?object + { + $loadBalancer = $this->getSubscriptionLoadBalancer($topic); + return $loadBalancer->takeMessageQueue(); + } + + /** + * Get SubscriptionLoadBalancer for a topic. + * Caches route data per topic, creating load balancer on first access. + * + * @param string $topic Topic name + * @return SubscriptionLoadBalancer + */ + private function getSubscriptionLoadBalancer(string $topic): SubscriptionLoadBalancer + { + if (!isset($this->subscriptionRouteDataCache[$topic])) { + $routeData = $this->getRouteData($topic); + $this->subscriptionRouteDataCache[$topic] = new SubscriptionLoadBalancer($routeData); + } + + return $this->subscriptionRouteDataCache[$topic]; + } + + /** + * Query route data for a topic via QueryRoute RPC. + * + * @param string $topic Topic name + * @return \Apache\Rocketmq\V2\QueryRouteResponse|null + */ + private function getRouteData(string $topic): ?object + { + $topicResource = new Resource(); + $topicResource->setName($topic); + + $request = new QueryRouteRequest(); + $request->setTopic($topicResource); + $request->setEndpoints($this->getParsedEndpoints()); + + // Set gRPC deadline using operation-specific timeout + $queryRouteTimeoutMs = $this->getOperationTimeout('QUERY_ROUTE') / 1000; // Convert to milliseconds for buildMetadata + $metadata = $this->buildMetadata($queryRouteTimeoutMs); + + // Also set client-side timeout as safety net (in microseconds) + $callOptions = ['timeout' => $this->getOperationTimeout('QUERY_ROUTE')]; + + try { + list($response, $status) = $this->client->QueryRoute($request, $metadata, $callOptions)->wait(); + + if ($status->code !== 0) { + $this->logger->warning("QueryRoute failed for topic={$topic}: " . $status->details); + return null; + } + + return $response; + } catch (\Exception $e) { + $this->logger->warning("QueryRoute exception for topic={$topic}: " . $e->getMessage()); + return null; + } + } + + /** + * Acknowledge messages. + * + * @param array $messages List of message objects to acknowledge + * @return void + * @throws \RuntimeException If consumer is not started + */ + public function ack(array $messages): void + { + if (!$this->isStarted) { + throw new \RuntimeException("Consumer not started"); + } + + if (empty($messages)) { + return; + } + + // Group messages by topic and broker endpoint for ack + $messagesByGroup = []; + foreach ($messages as $message) { + $topic = $this->extractTopic($message); + if (!$topic) { + continue; + } + $brokerKey = $this->getMessageBrokerKey($message); + $groupKey = $topic . '|'. $brokerKey; + if (!isset($messagesByGroup[$groupKey])) { + $messagesByGroup[$groupKey] = [ + 'topic' => $topic, + 'brokerClient' => $this->getBrokerClientForMessageView($message), + 'messages' => [], + ]; + } + $messagesByGroup[$groupKey]['messages'][] = $message; + } + + foreach ($messagesByGroup as $group) { + $this->ackMessagesForTopic($group['topic'], $group['messages'], $group['brokerClient']); + } + } + + /** + * Get the gRPC broker client for a message view endpoints. + * Creates a new client if one does not exist for the given endpoints. + * + * @throws \RuntimeException If message view does not have endpoints + * + * @param object $messageView MessageView object + * @return MessagingServiceClient gRPC client instance + */ + private function getBrokerClientForMessageView(object $messageView): MessagingServiceClient + { + if ($messageView instanceof MessageViewInterface) { + $endpoints = $messageView->getEndpoints(); + if ($endpoints !== null) { + $addresses = $endpoints->getAddresses(); + $addressesArray = ProtobufUtil::repeatedFieldToArray($addresses); + if (!empty($addressesArray) && $addressesArray[0] !== null) { + $address = $addressesArray[0]; + $brokerKey = $address->getHost() . ':' . $address->getPort(); + return RpcClientManager::getInstance()->getClient($brokerKey, [ + 'tlsCredentials' => $this->tlsCredentials, + 'sslEnabled' => $this->sslEnabled, + ]); + } + } + } + $this->logger->debug("SimpleConsumer: no broker endpoints in message, falling back to proxy client"); + return $this->client; + } + + /** + * Acknowledge messages for a specific topic with retry logic. + * + * @param string $topic Topic name + * @param array $messages List of message objects to acknowledge + * @param MessagingServiceClient $brokerClient gRPC client instance + * @return void + */ + private function ackMessagesForTopic(string $topic, array $messages, MessagingServiceClient $brokerClient): void + { + $entries = []; + foreach ($messages as $message) { + $receiptHandle = $this->extractReceiptHandle($message); + $messageId = $this->extractMessageId($message); + + if (!$receiptHandle) { + $this->logger->warning("Skip ack: no receipt handle for message"); + continue; + } + + $entry = new AckMessageEntry(); + if ($messageId) { + $entry->setMessageId($messageId); + } + $entry->setReceiptHandle($receiptHandle); + $entries[] = $entry; + } + + if (empty($entries)) { + return; + } + + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + + $topicResource = new Resource(); + $topicResource->setName($topic); + + $request = new AckMessageRequest(); + $request->setGroup($groupResource); + $request->setTopic($topicResource); + $request->setEntries($entries); + + // Set gRPC deadline using operation-specific timeout + $ackTimeoutMs = $this->getOperationTimeout('ACK_MESSAGE') / 1000; // Convert to milliseconds for buildMetadata + $metadata = $this->buildMetadata($ackTimeoutMs); + + // Also set client-side timeout as safety net (in microseconds) + $callOptions = ['timeout' => $this->getOperationTimeout('ACK_MESSAGE')]; + + $attempt = 0; + $maxRetries = 16; + $successCount = 0; + $failureCount = 0; + + while ($attempt < $maxRetries) { + try { + list($response, $status) = $brokerClient->AckMessage($request, $metadata, $callOptions)->wait(); + if ($status->code !== 0) { + $this->logger->warning("AckMessage attempt {$attempt}: gRPC status error: " . $status->details); + } else { + $responseEntries = $response->getEntries() ? ProtobufUtil::repeatedFieldToArray($response->getEntries()) : []; + if (!ProtobufUtil::isRepeatedFieldEmpty($responseEntries)) { + // Collect indices to remove (successful or permanent failure entries) + $indicesToRemove = []; + + foreach ($responseEntries as $i => $resultEntry) { + $messageId = isset($entries[$i]) ? $entries[$i]->getMessageId() : 'unknown'; + + if ($resultEntry->hasStatus()) { + $entryCode = $resultEntry->getStatus()->getCode(); + + if ($entryCode === 20000) { + // SUCCESS - mark for removal + $successCount++; + $indicesToRemove[] = $i; + $this->logger->debug("AckMessage success for messageId={$messageId}"); + } elseif ($entryCode === 40013) { + // INVALID_RECEIPT_HANDLE - permanent failure, mark for removal + $failureCount++; + $indicesToRemove[] = $i; + $this->logger->warning("AckMessage failed with INVALID_RECEIPT_HANDLE for messageId={$messageId}, not retrying"); + } else { + // Other errors - check if retryable + $isRetryable = $this->isRetryableErrorCode($entryCode); + if (!$isRetryable) { + // Permanent failure - mark for removal + $failureCount++; + $indicesToRemove[] = $i; + $this->logger->warning("AckMessage permanent error code={$entryCode} for messageId={$messageId}, not retrying"); + } else { + // Retryable - keep in array + $this->logger->debug("AckMessage retryable error code={$entryCode} for messageId={$messageId}, will retry"); + } + } + } else { + // No status in response entry, keep for retry + $this->logger->debug("AckMessage no status for messageId={$messageId}, will retry"); + } + } + + // If all entries are terminal (success or permanent failure), we're finished + if (count($indicesToRemove) === count($entries)) { + $this->logger->info("AckMessage batch completed: success={$successCount}, failure={$failureCount}"); + return; + } + + // Remove terminal entries in reverse order to maintain indices + if (!empty($indicesToRemove)) { + rsort($indicesToRemove); + foreach ($indicesToRemove as $index) { + array_splice($entries, $index, 1); + } + } + + // Update request with remaining retryable entries + $request->setEntries($entries); + $attempt++; + SwooleCompat::sleep(1000000); + continue; + } + // Empty response entries, consider all successful + $successCount = count($entries); + $this->logger->info("AckMessage batch completed with empty response: success={$successCount}"); + return; + } + } catch (\Exception $e) { + $this->logger->warning("AckMessage attempt {$attempt} failed: " . $e->getMessage()); + } + $attempt++; + SwooleCompat::sleep(1000000); + } + + // Exhausted all retries + $remainingCount = count($entries); + $failureCount += $remainingCount; + $this->logger->error("AckMessage exceeded max retries {$maxRetries}, giving up. Remaining entries: {$remainingCount}, Total failures: {$failureCount}"); + } + + + /** + * Get a unique broker key from a message view for grouping purposes. + * + * @param object $messageView Message to extract key from + * @return string Message broker key + */ + private function getMessageBrokerKey(object $messageView): string + { + if ($messageView instanceof MessageViewInterface) { + $endpoints = $messageView->getEndpoints(); + if ($endpoints !== null) { + $addresses = $endpoints->getAddresses(); + $addressesArray = ProtobufUtil::repeatedFieldToArray($addresses); + if (!empty($addressesArray) && $addressesArray[0] !== null) { + $address = $addressesArray[0]; + return $address->getHost() . ':' . $address->getPort(); + } + } + } + return 'proxy'; + } + + /** + * Change message visibility duration + * + * @param object $message Message to modify + * @param int $invisibleDurationSeconds New invisible duration + * @return bool true if operation succeeded, false if skipped or failed + * @throws \RuntimeException If consumer is not started + */ + public function changeInvisibleDuration(object $message, int $invisibleDurationSeconds): bool + { + if (!$this->isStarted) { + throw new \RuntimeException("Consumer not started"); + } + + $receiptHandle = $this->extractReceiptHandle($message); + $messageId = $this->extractMessageId($message); + $topic = $this->extractTopic($message); + + if (!$receiptHandle) { + $this->logger->warning("SimpleConsumer changeInvisibleDuration: no receipt handle, skipping"); + return false; + } + + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + + $topicResource = new Resource(); + $topicResource->setName($topic); + + $duration = new Duration(); + $duration->setSeconds($invisibleDurationSeconds); + $duration->setNanos(0); + + $request = new \Apache\Rocketmq\V2\ChangeInvisibleDurationRequest(); + $request->setGroup($groupResource); + $request->setTopic($topicResource); + $request->setReceiptHandle($receiptHandle); + $request->setInvisibleDuration($duration); + if ($messageId) { + $request->setMessageId($messageId); + } + + // Set gRPC deadline in metadata (server-side enforcement) + $metadata = $this->buildMetadata($this->requestTimeout); + $grpcTimeoutUs = $this->getOperationTimeout('CHANGE_INVISIBLE'); + $metadata['grpc-timeout'] = [$grpcTimeoutUs . 'u']; // microseconds + + // Also set client-side timeout as safety net + $callOptions = ['timeout' => $grpcTimeoutUs]; + + $brokerClient = $this->getBrokerClientForMessageView($message); + + try { + list($response, $status) = $brokerClient->ChangeInvisibleDuration($request, $metadata, $callOptions)->wait(); + if ($status->code !== 0) { + $this->logger->warning("SimpleConsumer changeInvisibleDuration failed: " . $status->details); + return false; + } + // Update receipt handle with the new one returned by server + if ($response->getReceiptHandle() !== '') { + $sysProps = $message->getSystemProperties(); + if ($sysProps !== null) { + $sysProps->setReceiptHandle($response->getReceiptHandle()); + } + } + return true; + } catch (\Exception $e) { + $this->logger->error("SimpleConsumer changeInvisibleDuration exception: " . $e->getMessage()); + return false; + } + } + + /** + * Notify server that this client is terminating. + * + * @return void + */ + private function notifyClientTermination(): void + { + $request = new NotifyClientTerminationRequest(); + $groupResource = new Resource(); + $groupResource->setName($this->consumerGroup); + $request->setGroup($groupResource); + + // Set gRPC deadline in metadata (server-side enforcement) + $metadata = $this->buildMetadata($this->requestTimeout); + $grpcTimeoutUs = $this->getOperationTimeout('HEARTBEAT'); + $metadata['grpc-timeout'] = [$grpcTimeoutUs . 'u']; // microseconds + + // Also set client-side timeout as safety net + $callOptions = ['timeout' => $grpcTimeoutUs]; + + try { + list($response, $status) = $this->client->NotifyClientTermination($request, $metadata, $callOptions)->wait(); + if ($status->code === 0) { + $this->logger->debug("NotifyClientTermination sent successfully"); + } else { + $this->logger->warning("NotifyClientTermination failed: " . $status->details); + } + } catch (\Exception $e) { + $this->logger->warning("NotifyClientTermination exception: " . $e->getMessage()); + } + } + + /** + * Shut down the consumer + * + * @return void + */ + public function shutdown(): void + { + if (!$this->isStarted) { + return; + } + + $this->stopHeartbeat(); + + $this->notifyClientTermination(); + + if ($this->telemetrySession) { + $this->telemetrySession->close(); + } + + $this->isStarted = false; + } + + /** + * Start periodic heartbeat via SIGALRM timer. + * Sends initial heartbeat then schedules recurring heartbeat every 10s. + * + * @return void + */ + public function startHeartbeat(): void + { + $this->doHeartbeat(); + $this->lastHeartbeatTime = time(); + // Prefer Swoole tick timer in coroutine context (non-blocking, no signal overhead) + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + $this->heartbeatTimerId = SwooleCompat::tick(10000, function () { + if ($this->heartbeatInProgress) { + $this->logger->debug("Swoole tick but heartbeat already in progress, skipping"); + return; + } + $this->heartbeatInProgress = true; + try { + $this->doHeartbeat(); + $this->lastHeartbeatTime = time(); + } catch (\Throwable $e) { + $this->logger->warning("Swoole tick heartbeat failed: " . $e->getMessage()); + } finally { + $this->heartbeatInProgress = false; + } + }); + if ($this->heartbeatCoroutineId >0 ) { + $this->logger->debug("Heartbeat started with Swoole timer (ID : {$this->heartbeatTimerId}"); + return; + } + } + + if (function_exists('pcntl_signal')) { + $self = $this; + pcntl_signal(SIGALRM, function () use ($self) { + // Concurrency guard: prevent reentrant heartbeat + if ($self->heartbeatInProgress) { + $self->logger->debug("SIGALRM received but heartbeat already in progress, skipping"); + $self->scheduleNextHeartbeat(); + return; + } + + $self->heartbeatInProgress = true; + try { + $self->doHeartbeat(); + $self->lastHeartbeatTime = time(); + } catch (\Throwable $e) { + $self->logger->warning("SIGALRM heartbeat failed: " . $e->getMessage()); + } finally { + $self->heartbeatInProgress = false; + $self->scheduleNextHeartbeat(); + } + }, true); // restart = true to handle nested signals + } + + // Schedule recurring heartbeat + $this->scheduleNextHeartbeat(); + } + + /** + * Schedule next heartbeat alarm in 10 seconds. + * + * @return void + */ + private function scheduleNextHeartbeat(): void + { + if (function_exists('pcntl_alarm')) { + pcntl_alarm(10); + } + } + + /** + * Stop heartbeat timer (cancel SIGALRM). + * + * @return void + */ + public function stopHeartbeat(): void + { + if ($this->heartbeatTimerId > 0) { + SwooleCompat::clearTimer($this->heartbeatTimerId); + $this->heartbeatTimerId = -1; + $this->logger->debug("Swoole heartbeat timer cleared"); + } + if ($this->heartbeatCoroutineId !== null) { + \Swoole\Coroutine::cancel($this->heartbeatCoroutineId); + $this->logger->info("Swoole coroutine heartbeat stopped, coroutine_id=" . $this->heartbeatCoroutineId); + $this->heartbeatCoroutineId = null; + } + + // Cancel pending alarm + if (function_exists('pcntl_alarm')) { + pcntl_alarm(0); + } + + // Reset signal handler to default + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGALRM, SIG_DFL); + } + + // Wait for any in-progress heartbeat to complete + $waitCount = 0; + while ($this->heartbeatInProgress && $waitCount < 10) { + SwooleCompat::sleep(10000); // Wait 10ms + $waitCount++; + } + + if ($this->heartbeatInProgress) { + $this->logger->warning("Heartbeat still in progress after waiting, forcing shutdown"); + } else { + $this->logger->debug("Heartbeat timer stopped cleanly"); + } + } + + /** + * Asynchronously receive messages via Swoole coroutine. + * + * @param int $maxMessages Maximum messages + * @param int $invisibleDuration Invisible duration in seconds + * @return array|\Generator Array of messages when Swoole available, Generator otherwise + */ + public function receiveAsync(int $maxMessages = 10, int $invisibleDuration = 30): array|\Generator + { + if (SwooleCompat::isAvailable() && !SwooleCompat::inCoroutine()) { + $self = $this; + $channel = new \Swoole\Coroutine\Channel(1); + \Swoole\Coroutine::create(function () use ($self, $maxMessages, $invisibleDuration, $channel) { + try { + $messages = $self->receive($maxMessages, $invisibleDuration); + $channel->push(['result' => $messages]); + } catch (\Throwable $e) { + $channel->push(['exception' => $e]); + } + }); + // Add timeout to prevent permanent blocking + $data = $channel->pop($this->requestTimeout / 1000.0); // Convert ms to seconds + if ($data === false) { + throw new \RuntimeException("Receive async timeout after {$this->requestTimeout}ms"); + } + if (isset($data['exception'])) { + throw $data['exception']; + } + return $data['result']; + } + return $this->receiveSyncFallback($maxMessages, $invisibleDuration); + } + + /** + * Generator fallback for receiveAsync when Swoole is not available. + * + * @param int $maxMessages + * @param int $invisibleDuration + * @return \Generator + */ + private function receiveSyncFallback(int $maxMessages, int $invisibleDuration): \Generator + { + yield $this->receive($maxMessages, $invisibleDuration); + } + + /** + * Asynchronously acknowledge messages via Swoole coroutine. + * + * @param array $messages Messages to ack + * @return bool|\Generator True on success when Swoole available, Generator otherwise + */ + public function ackAsync(array $messages): bool|\Generator + { + if (SwooleCompat::isAvailable() && !SwooleCompat::inCoroutine()) { + $self = $this; + $channel = new \Swoole\Coroutine\Channel(1); + \Swoole\Coroutine::create(function () use ($self, $messages, $channel) { + try { + $self->ack($messages); + $channel->push(['success' => true]); + } catch (\Throwable $e) { + $channel->push(['exception' => $e]); + } + }); + // Add timeout to prevent permanent blocking + $data = $channel->pop($this->requestTimeout / 1000.0); // Convert ms to seconds + if ($data === false) { + throw new \RuntimeException("Ack async timeout after {$this->requestTimeout}ms"); + } + if (isset($data['exception'])) { + throw $data['exception']; + } + return $data['success']; + } + return $this->ackSyncFallback($messages); + } + + /** + * Generator fallback for ackAsync when Swoole is not available. + * + * @param array $messages + * @return \Generator + */ + private function ackSyncFallback(array $messages): \Generator + { + yield $this->ack($messages); + } + + /** + * Asynchronously change invisible duration via Swoole coroutine. + * + * @param object $message Message to modify + * @param int $invisibleDurationSeconds New invisible duration + * @return bool|\Generator True on success when Swoole available, Generator otherwise + */ + public function changeInvisibleDurationAsync(object $message, int $invisibleDurationSeconds): bool|\Generator + { + if (SwooleCompat::isAvailable() && !SwooleCompat::inCoroutine()) { + $self = $this; + $channel = new \Swoole\Coroutine\Channel(1); + \Swoole\Coroutine::create(function () use ($self, $message, $invisibleDurationSeconds, $channel) { + try { + $success = $self->changeInvisibleDuration($message, $invisibleDurationSeconds); + $channel->push(['success' => $success]); + } catch (\Throwable $e) { + $channel->push(['exception' => $e]); + } + }); + // Add timeout to prevent permanent blocking + $data = $channel->pop($this->requestTimeout / 1000.0); // Convert ms to seconds + if ($data === false) { + throw new \RuntimeException("Change invisible duration async timeout after {$this->requestTimeout}ms"); + } + if (isset($data['exception'])) { + throw $data['exception']; + } + return $data['success']; + } + return $this->changeInvisibleDurationSyncFallback($message, $invisibleDurationSeconds); + } + + /** + * Generator fallback for changeInvisibleDurationAsync when Swoole is not available. + * + * @param object $message + * @param int $invisibleDurationSeconds + * @return \Generator + */ + private function changeInvisibleDurationSyncFallback(object $message, int $invisibleDurationSeconds): \Generator + { + yield $this->changeInvisibleDuration($message, $invisibleDurationSeconds); + } + + /** + * Get the client ID. + * + * @return string + */ + public function getClientId(): string + { + return $this->clientId; + } + + /** + * Get the consumer group name. + * + * @return string + */ + public function getConsumerGroup(): string + { + return $this->consumerGroup; + } + + /** + * Check if heartbeat is currently in progress. + * + * @return bool + */ + public function isHeartbeatInProgress(): bool + { + return $this->heartbeatInProgress; + } + + /** + * Get the namespace. + * + * @return string + */ + public function getNamespace(): string + { + return $this->namespace; + } + + /** + * Set the namespace. + * + * @param string $namespace + * @return $this + */ + public function setNamespace(string $namespace): self + { + $this->namespace = $namespace; + return $this; + } + + /** + * Set credentials. + * + * @param SessionCredentials $credentials + * @return $this + */ + public function setCredentials(SessionCredentials $credentials): self + { + $this->credentials = $credentials; + return $this; + } + + /** + * Get the parsed endpoints Endpoints protobuf object. + * + * @return \Apache\Rocketmq\V2\Endpoints + */ + public function getParsedEndpoints(): \Apache\Rocketmq\V2\Endpoints + { + if ($this->parsedEndpoints === null) { + $this->parsedEndpoints = $this->parseEndpoints($this->endpoints); + } + return $this->parsedEndpoints; + } + + /** + * Get the await duration in seconds. + * + * @return int Long polling timeout in seconds + */ + public function getAwaitDuration(): int + { + return $this->awaitDuration; + } + + /** + * Get a gRPC client connected to the broker endpoints from a MessageQueue. + * Reuses cached client if available, creates new one otherwise. + * Falls back to proxy client if broker endpoints not available. + * + * @param MessageQueue $messageQueue + * @return MessagingServiceClient + */ + public function getBrokerClient(object $messageQueue): \Apache\Rocketmq\V2\MessagingServiceClient + { + $broker = $messageQueue->getBroker(); + if (!$broker || !$broker->hasEndpoints()) { + $this->logger->debug("Broker has no endpoints, falling back to proxy client"); + return $this->client; + } + + $endpointsProto = $broker->getEndpoints(); + $addresses = $endpointsProto->getAddresses(); + $addressArray = ProtobufUtil::repeatedFieldToArray($addresses); + if (empty($addressArray) || $addressArray[0] === null) { + $this->logger->debug("No addresses in broker endpoints, falling back to proxy client"); + return $this->client; + } + + $address = $addressArray[0]; + $brokerKey = $address->getHost() . ':' . $address->getPort(); + + // Reuse cached client if available + if (isset($this->brokerClients[$brokerKey])) { + $this->logger->debug("Reusing cached broker client for {$brokerKey}"); + return $this->brokerClients[$brokerKey]; + } + + // Create new gRPC client to broker + $this->logger->info("Creating new broker client for {$brokerKey}"); + $this->brokerClients[$brokerKey] = RpcClientManager::getInstance()->getClient($brokerKey, [ + 'tlsCredentials' => $this->tlsCredentials, + 'sslEnabled' => $this->sslEnabled, + ]); + + return $this->brokerClients[$brokerKey]; + } + + /** + * Get the underlying MessagingServiceClient. + * + * @return MessagingServiceClient + */ + public function getClient(): \Apache\Rocketmq\V2\MessagingServiceClient + { + return $this->client; + } + + /** + * Get credentials for signing (ClientTrait required method). + * + * @return SessionCredentials|null + */ + protected function getCredentials(): ?SessionCredentials + { + return $this->credentials; + } + + /** + * Get client ID value for signing (ClientTrait required method). + * + * @return string + */ + protected function getClientIdValue(): string + { + return $this->clientId; + } + + /** + * Get namespace value for signing (ClientTrait required method). + * + * @return string + */ + protected function getNamespaceValue(): string + { + return $this->namespace; + } + + /** + * Generate a unique attempt ID for receiveMessage retry tracking. + * + * @return string + */ + protected function generateAttemptId(): string + { + return 'php-' . uniqid('', true); + } + + /** + * Destructor - shuts down the consumer gracefully. + * + * @return void + */ + public function __destruct() + { + $this->shutdown(); + } + + /** + * Check if an error code is retryable (transient error). + * + * Retryable errors: + * - 50001: INTERNAL_SERVER_ERROR + * - 50002: HA_NOT_AVAILABLE + * - 50400: PROXY_TIMEOUT + * - 50401: MASTER_PERSISTENCE_TIMEOUT + * - 50402: SLAVE_PERSISTENCE_TIMEOUT + * - 42900: TOO_MANY_REQUESTS + * + * Non-retryable errors: + * - 20000: OK + * - 40013: INVALID_RECEIPT_HANDLE + * - Other 4xx client errors + * + * @param int $code Error code from Status + * @return bool True if the error is transient and should be retried + */ + private function isRetryableErrorCode(int $code): bool + { + // Server-side transient errors (5xx) + if ($code >= 50000 && $code < 60000) { + return true; + } + + return match ($code) { + 42900 => true, // TOO_MANY_REQUESTS + default => false, + }; + } + + /** + * Filter entries that need to be retried based on response status. + * Deprecated: Use inline logic in ackMessage() instead. + * + * @param array $entries Original request entries + * @param array $responseEntries Response entries with status + * @return array Entries that should be retried + * @deprecated This method is no longer used. Logic moved to ackMessage(). + */ + private function filterRetryableEntries(array $entries, array $responseEntries): array + { + $responseCodes = []; + foreach ($responseEntries as $i => $resultEntry) { + if ($resultEntry->hasStatus()) { + $responseCodes[$i] = $resultEntry->getStatus()->getCode(); + } + } + $retryable = []; + foreach ($entries as $i => $entry) { + if (!isset($responseCodes[$i]) || $this->isRetryableErrorCode($responseCodes[$i])) { + $retryable[] = $entry; + } + } + return $retryable; + } +} diff --git a/php/SimpleConsumerBuilder.php b/php/SimpleConsumerBuilder.php new file mode 100644 index 000000000..fd5192f74 --- /dev/null +++ b/php/SimpleConsumerBuilder.php @@ -0,0 +1,270 @@ + 'filterExpression'] map + * - awaitDuration: 30 Long-poll wait time in seconds for receive() + * - namespace: '' Resource namespace prefix + * - credentials: null AK/SK SessionCredentials for authentication + * - tlsCredentials: null TlsCredentials for custom TLS configuration + * + * Usage example — basic simple consumer: + * ```php + * $consumer = (new SimpleConsumerBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setConsumerGroup('my-group') + * ->subscribe('TopicA', '*') + * ->setAwaitDuration(15) + * ->build(); + * + * while (true) { + * $messages = $consumer->receive(16, 15); + * foreach ($messages as $mv) { + * // process message + * $consumer->ack($mv); + * } + * } + * ``` + * + * Usage example — with ClientConfiguration: + * ```php + * $config = (new ClientConfigurationBuilder()) + * ->setEndpoints('127.0.0.1:8081') + * ->setSessionCredentialsProvider(new SessionCredentials('ak', 'sk')) + * ->build(); + * + * $consumer = (new SimpleConsumerBuilder()) + * ->setClientConfiguration($config) + * ->setConsumerGroup('my-group') + * ->subscribe('TopicA') + * ->subscribe('TopicB', 'tagX || tagY') + * ->build(); + * ``` + * + * @see SimpleConsumer + * @see ClientConfiguration + */ +class SimpleConsumerBuilder +{ + private string $endpoints = ''; + private string $consumerGroup = ''; + private ?SessionCredentials $credentials = null; + private array $subscriptionExpressions = []; + private int $awaitDuration = 30; + private string $namespace = ''; + private ?TlsCredentials $tlsCredentials = null; + + /** + * Bulk-import settings from a {@see ClientConfiguration} instance. + * + * Copies endpoints, credentials, namespace, and tlsCredentials from the + * config object. Individual setter calls made **after** this method will + * override the imported values. + * + * @param ClientConfiguration $config Pre-built client configuration + * @return $this For method chaining + */ + public function setClientConfiguration(ClientConfiguration $config): self + { + $this->endpoints = $config->getEndpoints(); + $this->credentials = $config->getSessionCredentialsProvider(); + $this->namespace = $config->getNamespace(); + if ($config->getTlsCredentials() !== null) { + $this->tlsCredentials = $config->getTlsCredentials(); + } + return $this; + } + + /** + * Set the consumer group name. + * + * The consumer group identifies this consumer on the server side. All + * consumers sharing the same group name will load-balance messages; + * consumers in different groups each receive a full copy of every message. + * This is a required setting. + * + * @param string $consumerGroup Consumer group name (must match server-side group config) + * @return $this For method chaining + * @default '' (empty — build() will throw) + */ + public function setConsumerGroup(string $consumerGroup): self + { + $this->consumerGroup = $consumerGroup; + return $this; + } + + /** + * Bulk-set subscription expressions (topic => filter expression map). + * + * Replaces any previously registered subscriptions. Filter expressions + * follow the RocketMQ SQL92 or tag syntax: + * - '*': match all messages + * - 'tagA': match messages with tag "tagA" + * - 'tagA || tagB': match messages with tag "tagA" or "tagB" + * - SQL92: 'color = "red" AND price > 100' + * + * At least one subscription is required; build() throws if empty. + * + * @param array $expressions Associative array ['topicName' => 'filterExpression'] + * @return $this For method chaining + * @default [] (no subscriptions) + * @see subscribe() for adding subscriptions one by one + */ + public function setSubscriptionExpressions(array $expressions): self + { + $this->subscriptionExpressions = $expressions; + return $this; + } + + /** + * Subscribe to a single topic. + * + * Convenience method that adds to (not replaces) the subscription map. + * Calling subscribe('TopicA', '*') then subscribe('TopicB', 'tagX') is + * equivalent to setSubscriptionExpressions(['TopicA' => '*', 'TopicB' => 'tagX']). + * Subscribing to the same topic twice overwrites the previous expression. + * + * @param string $topic Topic name to subscribe to + * @param string $expression Filter expression (tag or SQL92 syntax) + * @return $this For method chaining + * @default expression is '*' (match all) + */ + public function subscribe(string $topic, string $expression = '*'): self + { + $this->subscriptionExpressions[$topic] = $expression; + return $this; + } + + /** + * Set long-polling await duration in seconds. + * + * Controls how long the receive() call blocks waiting for new messages. + * The consumer sends a ReceiveMessageRequest to the broker with this + * timeout; if no messages are available within this window, the broker + * returns an empty response and the consumer retries. + * + * Longer durations reduce network round-trips and CPU usage at the cost + * of slightly higher latency for detecting new messages. Recommended + * range: 10–30 seconds. Values below 5 seconds cause excessive polling. + * + * This value is also used as the per-request timeout for each + * long-poll ReceiveMessage gRPC call. + * + * @param int $seconds Await duration in seconds + * @return $this For method chaining + * @default 30 + * @valid-range Must be > 0 + * @throws \InvalidArgumentException if $seconds <= 0 + */ + public function setAwaitDuration(int $seconds): self + { + if ($seconds <= 0) { + throw new \InvalidArgumentException("awaitDuration must be > 0"); + } + $this->awaitDuration = $seconds; + return $this; + } + + /** + * Set the resource namespace prefix. + * + * @param string $namespace Namespace string (empty string = no namespace) + * @return $this For method chaining + * @default '' (no namespace) + */ + public function setNamespace(string $namespace): self + { + $this->namespace = $namespace; + return $this; + } + + /** + * Set custom TLS credentials for the gRPC connection. + * + * @param TlsCredentials $tlsCredentials TLS certificate configuration + * @return $this For method chaining + * @default null (use system trust store) + */ + public function setTlsCredentials(TlsCredentials $tlsCredentials): self + { + $this->tlsCredentials = $tlsCredentials; + return $this; + } + + /** + * Build and start the SimpleConsumer. + * + * Behavior: + * 1. Validates all required fields (endpoints, consumerGroup, subscriptions). + * 2. Constructs a SimpleConsumer with all accumulated settings. + * 3. Calls SimpleConsumer::start() which establishes the TelemetrySession, + * registers settings callbacks, and starts the HeartbeatManager. + * 4. Returns a started consumer ready for receive() calls. + * + * Validation rules (all throw \RuntimeException): + * - endpoints must be set (non-empty) + * - consumerGroup must be set (non-empty) + * - at least one subscription must be registered via setSubscriptionExpressions() + * or subscribe() + * + * @return SimpleConsumer A started SimpleConsumer ready for receive() + * @throws \RuntimeException If endpoints is not set + * @throws \RuntimeException If consumerGroup is not set + * @throws \RuntimeException If no subscriptions are registered + * @throws \RuntimeException If start() fails (e.g. gRPC connection refused) + * @throws \InvalidArgumentException If awaitDuration was set to an invalid value + */ + public function build(): SimpleConsumer + { + if ($this->endpoints === '') { + throw new \RuntimeException("SimpleConsumer endpoints must be set"); + } + if ($this->consumerGroup === '') { + throw new \RuntimeException("SimpleConsumer consumerGroup must be set"); + } + if (empty($this->subscriptionExpressions)) { + throw new \RuntimeException("SimpleConsumer must have at least one subscription"); + } + + $consumer = new SimpleConsumer($this->endpoints, $this->consumerGroup, [ + 'subscriptionExpressions' => $this->subscriptionExpressions, + 'awaitDuration' => $this->awaitDuration, + 'namespace' => $this->namespace, + 'credentials' => $this->credentials, + 'tlsCredentials' => $this->tlsCredentials, + ]); + + $consumer->start(); + return $consumer; + } +} diff --git a/php/SipHash24.php b/php/SipHash24.php new file mode 100644 index 000000000..62583abd9 --- /dev/null +++ b/php/SipHash24.php @@ -0,0 +1,320 @@ +k0 = (int)$k0 & self::MASK_64; + $this->k1 = (int)$k1 & self::MASK_64; + } + + /** + * Convenience static method for quick hashing with default key. + * + * @param string $data Input data + * @return int|float 64-bit hash value (may be float on 32-bit PHP if value exceeds PHP_INT_MAX) + */ + public static function hash(string $data): int + { + $instance = new self(); + return $instance->hashBytes($data); + } + + /** + * Compute SipHash-2-4 of the given bytes. + * + * @param string $data Input data + * @return int|float 64-bit hash value (may be float on 32-bit PHP if value exceeds PHP_INT_MAX) + */ + public function hashBytes(string $data): int + { + $length = strlen($data); + + // Initialize state with the standard SipHash constants + // ("somepseudorandomlygeneratedbytes" split into four 64-bit words) + $v0 = (int)($this->k0 ^ 0x736f6d6570736575); + $v1 = (int)($this->k1 ^ 0x646f72616e646f6d); + $v2 = (int)($this->k0 ^ 0x6c7967656e657261); + $v3 = (int)($this->k1 ^ 0x7465646279746573); + + // Process full 8-byte blocks + $blocks = intdiv($length, 8); + for ($i = 0; $i < $blocks; $i++) { + $m = $this->readLong($data, $i * 8); + $v3 = self::xor64($v3, $m); + list($v0, $v1, $v2, $v3) = $this->sipRound($v0, $v1, $v2, $v3); + list($v0, $v1, $v2, $v3) = $this->sipRound($v0, $v1, $v2, $v3); + $v0 = self::xor64($v0, $m); + } + + // Build last block: length in the top byte, remaining input bytes + // packed little-endian (byte i of the tail goes to bits i*8..i*8+7) + $b = (int)(($length & 0xFF) << 56); + $offset = $blocks * 8; + $left = $length - $offset; + for ($i = 0; $i < $left; $i++) { + $b |= (ord($data[$offset + $i]) & 0xFF) << ($i * 8); + } + + $v3 = self::xor64($v3, $b); + list($v0, $v1, $v2, $v3) = $this->sipRound($v0, $v1, $v2, $v3); + list($v0, $v1, $v2, $v3) = $this->sipRound($v0, $v1, $v2, $v3); + $v0 = self::xor64($v0, $b); + + $v2 = (int)(self::xor64($v2, 0xFF)); + + // Finalization: 4 rounds + list($v0, $v1, $v2, $v3) = $this->sipRound($v0, $v1, $v2, $v3); + list($v0, $v1, $v2, $v3) = $this->sipRound($v0, $v1, $v2, $v3); + list($v0, $v1, $v2, $v3) = $this->sipRound($v0, $v1, $v2, $v3); + list($v0, $v1, $v2, $v3) = $this->sipRound($v0, $v1, $v2, $v3); + + return self::and64(self::xor64(self::xor64($v0, $v1), self::xor64($v2, $v3)), self::MASK_64); + } + + /** + * Read a little-endian 64-bit integer from $data at $offset. + * + * @param string $data Input byte string + * @param int $offset Byte offset to read from + * @return int|float 64-bit value (may be float on 32-bit PHP) + */ + private function readLong(string $data, int $offset): int + { + if (PHP_INT_SIZE >= 8) { + // 64-bit PHP: direct calculation, byte i is bits i*8..i*8+7 (little-endian) + $result = 0; + for ($i = 0; $i < 8; $i++) { + $result |= (ord($data[$offset + $i]) & 0xFF) << ($i * 8); + } + return (int)$result & self::MASK_64; + } else { + // 32-bit PHP: split into high and low 32-bit parts + $low = 0; + $high = 0; + + // Low 32 bits (bytes 0-3, little-endian) + for ($i = 0; $i < 4; $i++) { + $low |= (ord($data[$offset + $i]) & 0xFF) << ($i * 8); + } + + // High 32 bits (bytes 4-7, little-endian) + for ($i = 4; $i < 8; $i++) { + $high |= (ord($data[$offset + $i]) & 0xFF) << (($i - 4) * 8); + } + + return ($high << 32) | ($low & 0xFFFFFFFF); + } + } + + /** + * One SipRound mixing operation. + * + * @param int|float $v0 State word 0 + * @param int|float $v1 State word 1 + * @param int|float $v2 State word 2 + * @param int|float $v3 State word 3 + * @return array Array of four state words [$v0, $v1, $v2, $v3] + */ + private function sipRound(int $v0, int $v1, int $v2, int $v3): array + { + $v0 = self::add64($v0, $v1); + $v1 = self::rotl64($v1, 13); + $v1 = self::xor64($v1, $v0); + $v0 = self::rotl64($v0, 32); + + $v2 = self::add64($v2, $v3); + $v3 = self::rotl64($v3, 16); + $v3 = self::xor64($v3, $v2); + + $v0 = self::add64($v0, $v3); + $v3 = self::rotl64($v3, 21); + $v3 = self::xor64($v3, $v0); + + $v2 = self::add64($v2, $v1); + $v1 = self::rotl64($v1, 17); + $v1 = self::xor64($v1, $v2); + $v2 = self::rotl64($v2, 32); + + return [$v0, $v1, $v2, $v3]; + } + + /** + * 64-bit addition, works on both 64-bit and 32-bit PHP. + * + * @param int|float $a First 64-bit operand + * @param int|float $b Second 64-bit operand + * @return int|float 64-bit sum masked to 64 bits + */ + private static function add64(int $a, int $b): int + { + if (PHP_INT_SIZE >= 8) { + // Split into 32-bit halves: native + would overflow to float on + // 64-bit PHP, silently losing low-order bits + $low = ($a & 0xFFFFFFFF) + ($b & 0xFFFFFFFF); + $high = (($a >> 32) & 0xFFFFFFFF) + (($b >> 32) & 0xFFFFFFFF) + (($low >> 32) & 0x1); + return (($high & 0xFFFFFFFF) << 32) | ($low & 0xFFFFFFFF); + } + + // Split into high and low 32-bit parts + $ah = (int)(($a >> 32) & 0xFFFFFFFF); + $al = (int)($a & 0xFFFFFFFF); + $bh = (int)(($b >> 32) & 0xFFFFFFFF); + $bl = (int)($b & 0xFFFFFFFF); + + // Add low parts first + $sumL = (int)($al + $bl); + + // Check for carry (use comparison to avoid float conversion) + $carry = 0; + if ($sumL < 0 || ($al > 0 && $bl > 0 && $sumL < $al)) { + $carry = 1; + $sumL = (int)($sumL & 0xFFFFFFFF); + } + + // Add high parts with carry + $sumH = (int)(($ah + $bh + $carry) & 0xFFFFFFFF); + + return ($sumH << 32) | $sumL; + } + + /** + * 64-bit XOR. + * + * @param int|float $a First 64-bit operand + * @param int|float $b Second 64-bit operand + * @return int|float 64-bit XOR result masked to 64 bits + */ + private static function xor64(int $a, int $b): int + { + if (PHP_INT_SIZE >= 8) { + return ((int)$a ^ (int)$b) & self::MASK_64; + } + + $aHi = (int)(($a >> 32) & 0xFFFFFFFF); + $alower = (int)($a & 0xFFFFFFFF); + $bHi = (int)(($b >> 32) & 0xFFFFFFFF); + $blower = (int)($b & 0xFFFFFFFF); + + return (($aHi ^ $bHi) << 32) | ($alower ^ $blower); + } + + /** + * 64-bit AND mask. + * + * @param int|float $a 64-bit value to mask + * @param int $mask 64-bit mask value + * @return int|float 64-bit masked result + */ + private static function and64(int $a, int $mask): int + { + if (PHP_INT_SIZE >= 8) { + return (int)($a & $mask); + } + + $aHi = (int)(($a >> 32) & 0xFFFFFFFF); + $alower = (int)($a & 0xFFFFFFFF); + $mHi = (int)(($mask >> 32) & 0xFFFFFFFF); + $mlower = (int)($mask & 0xFFFFFFFF); + + return (($aHi & $mHi) << 32) | ($alower & $mlower); + } + + /** + * 64-bit left rotate. + * + * @param int|float $a 64-bit value to rotate + * @param int $n Number of bits to rotate left + * @return int|float 64-bit rotated result masked to 64 bits + */ + private static function rotl64(int $a, int $n): int + { + if (PHP_INT_SIZE >= 8) { + $n &= 63; + if ($n === 0) { + return (int)$a; + } + // Mask after the right shift: PHP's >> is arithmetic and would + // sign-extend negative values into the rotated-in bits + return (((int)$a << $n) | (((int)$a >> (64 - $n)) & ((1 << $n) - 1))) & self::MASK_64; + } + + $n &= 63; + if ($n === 0) { + return $a; + } + + $aHi = (int)(($a >> 32) & 0xFFFFFFFF); + $alower = (int)($a & 0xFFFFFFFF); + + if ($n >= 32) { + // Rotating by 32 swaps the halves; reduce to a rotation below 32 + $tmp = $aHi; + $aHi = $alower; + $alower = $tmp; + $n -= 32; + if ($n === 0) { + return ($aHi << 32) | $alower; + } + } + + $newHi = (int)((($aHi << $n) | (($alower >> (32 - $n)) & ((1 << $n) - 1))) & 0xFFFFFFFF); + $newlower = (int)((($alower << $n) | (($aHi >> (32 - $n)) & ((1 << $n) - 1))) & 0xFFFFFFFF); + + return ($newHi << 32) | $newlower; + } +} diff --git a/php/StatusChecker.php b/php/StatusChecker.php new file mode 100644 index 000000000..73d518976 --- /dev/null +++ b/php/StatusChecker.php @@ -0,0 +1,158 @@ + 1, + 'ILLEGAL_TOPIC' => 1, + 'ILLEGAL_CONSUMER_GROUP' => 1, + 'ILLEGAL_MESSAGE_TAG' => 1, + 'ILLEGAL_MESSAGE_KEY' => 1, + 'ILLEGAL_MESSAGE_GROUP' => 1, + 'ILLEGAL_MESSAGE_PROPERTY_KEY' => 1, + 'INVALID_TRANSACTION_ID' => 1, + 'MESSAGE_CORRUPTED' => 1, + 'ILLEGAL_FILTER_EXPRESSION' => 1, + 'ILLEGAL_FILTER_SQL92_EXPRESSION' => 1, + 'INVALID_RECEIPT_HANDLE' => 1, + 'WRONG_ORGANIZATION' => 1, + 'ILLEGAL_LITE_TOPIC' => 1, + 'ILLEGAL_GLOBAL_BID' => 1, + + 'UNAUTHORIZED' => 2, + + 'PAYMENT_REQUIRED' => 3, + + 'FORBIDDEN' => 4, + 'FORBIDDEN_REUSE' => 4, + + 'NOT_FOUND' => 5, + 'TOPIC_NOT_FOUND' => 5, + 'CONSUMER_GROUP_NOT_FOUND' => 5, + + 'PAYLOAD_TOO_LARGE' => 6, + 'MESSAGE_BODY_TOO_LARGE' => 6, + + 'PAYLOAD_EMPTY' => 7, + 'MESSAGE_BODY_EMPTY' => 7, + + 'TOO_MANY_REQUESTS' => 8, + + 'LITE_TOPIC_QUOTA_EXCEEDED' => 9, + 'LITE_SUBSCRIPTION_QUOTA_EXCEEDED' => 10, + + 'REQUEST_HEADER_FIELDS_TOO_LARGE' => 11, + 'MESSAGE_PROPERTIES_TOO_LARGE' => 11, + + 'INTERNAL_ERROR' => 12, + 'INTERNAL_SERVER_ERROR' => 12, + 'HA_NOT_AVAILABLE' => 12, + + 'PROXY_TIMEOUT' => 13, + 'MASTER_PERSISTENCE_TIMEOUT' => 13, + 'SLAVE_PERSISTENCE_TIMEOUT' => 13, + + 'UNSUPPORTED' => 14, + 'VERSION_UNSUPPORTED' => 14, + 'VERIFY_FIFO_MESSAGE_UNSUPPORTED' => 14, + ]; + + /** + * Check gRPC status and throw appropriate exception on failure. + * + * @param \Google\Rpc\Status|null $status gRPC status object + * @param string $detailMessage Optional additional detail message + * @return void + * @throws BadRequestException|UnauthorizedException|ForbiddenException|NotFoundException|InternalErrorException|TooManyRequestsException|\Exception + */ + public static function check($status, $detailMessage = '') + { + if ($status === null) { + return; + } + + $code = $status->getCode(); + if ($code === 20000) { + return; + } + + $message = $status->getMessage(); + if (!empty($detailMessage)) { + $message = $message . '; detail: ' . $detailMessage; + } + + $exceptionClass = self::resolveExceptionClass($code); + throw new $exceptionClass($code, $message); + } + + /** + * Resolve the exception class name for a given status code. + * + * @param int $code gRPC status code + * @return string Fully qualified exception class name + */ + private static function resolveExceptionClass($code) + { + if (in_array($code, self::$badRequestCodes)) { + return BadRequestException::class; + } + + return match ($code) { + 40100 => UnauthorizedException::class, + 40200 => PaymentRequiredException::class, + 40300 => ForbiddenException::class, + 40400, 40401, 40402 => NotFoundException::class, + 41300, 41301 => PayloadTooLargeException::class, + 41400, 41401 => PayloadEmptyException::class, + 42900 => TooManyRequestsException::class, + 42901 => LiteTopicQuotaExceededException::class, + 42902 => LiteSubscriptionQuotaExceededException::class, + 43100, 43101 => RequestHeaderFieldsTooLargeException::class, + 50000, 50001, 50002 => InternalErrorException::class, + 50400, 50401, 50402 => ProxyTimeoutException::class, + 50100, 50101, 50102 => UnsupportedException::class, + default => ($code >= 40000 && $code < 50000) + ? BadRequestException::class + : InternalErrorException::class, + }; + } + + /** + * Map a status message string to a numeric code. + * + * @param string $statusMessage Human-readable status message + * @return int Numeric status code, defaults to 40000 + */ + public static function codeFromStatusMessage($statusMessage) + { + $upper = strtoupper(trim($statusMessage)); + foreach (self::$statusMessageMap as $key => $code) { + if (strpos($upper, $key) !== false) { + return $code; + } + } + return 40000; + } +} diff --git a/php/SubscriptionLoadBalancer.php b/php/SubscriptionLoadBalancer.php new file mode 100644 index 000000000..965c7d960 --- /dev/null +++ b/php/SubscriptionLoadBalancer.php @@ -0,0 +1,90 @@ +getMessageQueues(); + $readableCount = 0; + $masterCount = 0; + + // Accept READ or READ_WRITE for consumer + foreach ($allQueues as $queue) { + $permission = $queue->getPermission(); + $isMaster = $queue->getBroker()->getId() === ClientConstants::MASTER_BROKER_ID; + if (($permission === Permission::READ || $permission === Permission::READ_WRITE) && $isMaster) { + $this->messageQueues[] = $queue; + $masterCount++; + } + $readableCount++; + } + + Logger::getInstance('SubscriptionLoadBalancer')->info("Topic queues: {$masterCount} readable / {$readableCount} total"); + } + } + + /** + * Get next MessageQueue using round-robin selection. + * + * @return object|null The next MessageQueue or null if none available + */ + public function takeMessageQueue() + { + if (empty($this->messageQueues)) { + Logger::getInstance('SubscriptionLoadBalancer')->warning("No message queues available"); + return null; + } + + $index = $this->queueIndex++ % count($this->messageQueues); + $queue = $this->messageQueues[$index]; + + Logger::getInstance('SubscriptionLoadBalancer')->debug("Selected queue index: {$index}"); + + return $queue; + } + + /** + * Get all readable message queues. + * + * @return array List of filtered MessageQueue objects + */ + public function getMessageQueues() + { + return $this->messageQueues; + } +} diff --git a/php/SwooleCompat.php b/php/SwooleCompat.php new file mode 100644 index 000000000..8dc7b7df5 --- /dev/null +++ b/php/SwooleCompat.php @@ -0,0 +1,149 @@ + 0; + } + + /** + * Run a callback in a coroutine if not already in one. + * + * @param callable $fn Callback to execute + * @param float $timeout Timeout in seconds for coroutine execution + * @return mixed The return value of the callback + * @throws \RuntimeException If execution times out + */ + public static function runInCoroutine(callable $fn, float $timeout = 30.0) + { + if (!self::isAvailable()) { + return $fn(); + } + if (self::inCoroutine()) { + return $fn(); + } + $result = null; + $exception = null; + $channel = new \Swoole\Coroutine\Channel(1); + \Swoole\Coroutine::create(function () use ($fn, $channel, &$result, &$exception) { + try { + $result = $fn(); + } catch (\Throwable $e) { + $exception = $e; + } + $channel->push(true); + }); + $popped = $channel->pop($timeout); + if ($popped === false) { + throw new \RuntimeException("Swoole execution timed out after {$timeout}"); + } + if ($exception !== null) { + throw $exception; + } + return $result; + } + + /** + * Sleep for specified microseconds, using coroutine-friendly sleep if in Swoole context. + * + * @param int $microseconds Sleep duration in microseconds + * @return void + */ + public static function sleep(int $microseconds): void + { + if (self::isAvailable() && self::inCoroutine()) { + // Convert microseconds to seconds for Swoole coroutine sleep + $seconds = $microseconds / 1000000.0; + \Swoole\Coroutine::sleep($seconds); + } else { + // Traditional blocking sleep + usleep($microseconds); + } + } + + /** + * Sleep with on optional warning callback for non-Swoole blocking + * @param int $microseconds sleep duration in microseconds + * @param callable|null $warningCallback Called with a warning message + * @return void + */ + public static function sleepBlocking(int $microseconds, ?callable $warningCallback = null): void + { + if (!self::isAvailable() || !self::inCoroutine()) { + if ($warningCallback !== null) { + $warningCallback("Blocking sleeping for {$microseconds} microseconds in non-Swoole mode (process blocked)"); + } + } + self::sleep($microseconds); + } + + /** + * Create a recurring timer that invokes the callback at the specified interval. + * + * @param int $intervalMs Interval in milliseconds + * @param callable $callback Function to invoke on each tick + * @return int Timer ID (positive) on success, -1 if Swoole timer is unavailable + */ + public static function tick(int $intervalMs, callable $callback): int + { + if (!self::isAvailable() || !self::inCoroutine()) { + return -1; + } + + return \Swoole\Timer::tick($intervalMs, $callback); + } + + /** + * Clear a previously created timer. + * + * @param int $timerId Timer ID returned by tick() + * @return bool True if the timer was cleared, false if the timer ID was invalid + */ + public static function clearTimer(int $timerId): bool + { + if ($timerId < 0 || !self::isAvailable()) { + return false; + } + return \Swoole\Timer::clear($timerId); + } +} diff --git a/php/SystemPropertiesInterface.php b/php/SystemPropertiesInterface.php new file mode 100644 index 000000000..65758414c --- /dev/null +++ b/php/SystemPropertiesInterface.php @@ -0,0 +1,154 @@ +client = $client; + $this->endpoints = $endpoints; + $this->credentials = $credentials; + $this->namespace = $namespace; + $this->logger = Logger::getInstance('TelemetrySession'); + if ($clientId) { + $this->clientId = $clientId; + } + } + + /** + * Register callback for server settings changes. + * + * @param callable $callback Callback receiving server Settings + * @return void + */ + public function setOnSettingsChange(callable $callback): void + { + $this->onSettingsChange = $callback; + } + + /** + * Register callback for orphaned transaction recovery. + * + * @param callable $callback Callback receiving RecoverOrphanedTransactionCommand + * @return void + */ + public function setOnRecoverOrphanedTransaction(callable $callback): void + { + $this->onRecoverOrphanedTransaction = $callback; + } + + /** + * Register callback for message verification. + * + * @param callable $callback Callback receiving VerifyMessageCommand + * @return void + */ + public function setOnVerifyMessage(callable $callback): void + { + $this->onVerifyMessage = $callback; + } + + /** + * Register callback for printing thread stack trace. + * + * @param callable $callback Callback receiving PrintThreadStackTraceCommand + * @return void + */ + public function setOnPrintThreadStackTrace(callable $callback): void + { + $this->onPrintThreadStackTrace = $callback; + } + + /** + * Register callback for endpoint reconnection. + * + * @param callable $callback Callback receiving ReconnectEndpointsCommand + * @return void + */ + public function setOnReconnectEndpoints(callable $callback): void + { + $this->onReconnectEndpoints = $callback; + } + + /** + * Register callback for unsubscribe notification. + * + * @param callable $callback Callback receiving NotifyUnsubscribeLiteCommand + * @return void + */ + public function setOnNotifyUnsubscribeLite(callable $callback): void + { + $this->onNotifyUnsubscribeLite = $callback; + } + + /** + * Get the current server settings. + * + * @return object|null Server settings object or null + */ + public function getServerSettings() + { + return $this->serverSettings; + } + + /** + * Reset all session instances (mainly for testing). + * + * @return void + */ + public static function resetAll(): void + { + self::$instances = []; + self::$instanceTimestamps = []; + } + + /** + * Get or create a session instance for the given endpoints. + * + * @param object $client gRPC messaging service client + * @param string $endpoints Server endpoints + * @param string|null $clientId Client identifier + * @param SessionCredentials|null $credentials Session credentials + * @param string $namespace Resource namespace + * @return self + */ + public static function getInstance(object $client, string $endpoints, ?string $clientId = null, ?SessionCredentials $credentials = null, string $namespace = ''): self + { + $credId = $credentials !== null ? spl_object_id($credentials) : 'none'; + $effectiveClientId = $clientId ?? 'none'; + $key = $endpoints . '|' . $credId . '|' . $namespace . '|' . $effectiveClientId; + + if (isset(self::$instances[$key])) { + $existing = self::$instances[$key]; + $age = time() - (self::$instanceTimestamps[$key] ?? 0); + + if (!$existing->isAlive() || $age > self::SESSION_TTL_SECONDS) { + $reason = !$existing->isAlive() ? 'dead stream' : "TTL expired ({$age}s > " . self::SESSION_TTL_SECONDS . "s)"; + Logger::getInstance('TelemetrySession')->info("Evicting stale session for endpoints: {$endpoints}, reason: {$reason}"); + $existing->close(); + unset(self::$instances[$key]); + unset(self::$instanceTimestamps[$key]); + } + } + + if (!isset(self::$instances[$key])) { + if (count(self::$instances) >= self::MAX_INSTANCES) { + self::evictOldest(); + } + Logger::getInstance('TelemetrySession')->info("Creating new session for endpoints: {$endpoints}, clientId: {$effectiveClientId}"); + $instance = new self($client, $endpoints, $clientId, $credentials, $namespace); + self::$instances[$key] = $instance; + self::$instanceTimestamps[$key] = time(); + } + + return self::$instances[$key]; + } + + /** + * Check if this session is still alive. + * + * Health check hierarchy: + * 1. isClosing flag → immediately stale + * 2. Stream was created but is now closed → stale + * 3. Stream exists but write probe fails → stale + * 4. Session never started (no stream yet) → considered alive (pending start) + * + * @return bool + */ + private function isAlive(): bool + { + if ($this->isClosing) { + return false; + } + + if ($this->stream !== null) { + if ($this->isStreamClosed()) { + return false; + } + // Probe write capability: if write fails, the stream is stale + if (!$this->probeStreamWritable()) { + return false; + } + } + return true; + } + + /** + * Non-destructive probe to check if the stream is still writable. + * Sends a zero-length write which gRPC treats as a keepalive check. + * + * @return bool true if stream accepts writes + */ + private function probeStreamWritable(): bool + { + if ($this->stream === null) { + return false; + } + try { + // Attempt flush as a lightweight connectivity probe. + // gRPC flush will throw if the underlying channel is broken. + $this->stream->flush(); + return true; + } catch (\Throwable $e) { + $this->logger->debug("Stream write probe failed: " . $e->getMessage()); + return false; + } + } + + /** + * Check if the underlying stream is closed. + * + * @return bool + */ + private function isStreamClosed(): bool + { + if ($this->stream === null) { + return true; + } + try { + $status = $this->stream->getStatus(); + $code = is_object($status) ? ($status->code ?? -1) : (is_array($status) ? ($status['code'] ?? -1) : -1); + if ($code !== 0) { + return true; + } + } catch (\Exception $e) { + return true; + } + return false; + } + + /** + * Evict the oldest instance to make room for a new one. + * @return void + */ + private static function evictOldest(): void + { + $oldestKey = null; + $oldestTime = PHP_INT_MAX; + foreach (self::$instanceTimestamps as $key => $timestamp) { + if ($timestamp < $oldestTime) { + $oldestTime = $timestamp; + $oldestKey = $key; + } + } + + if ($oldestKey !== null) { + Logger::getInstance('TelemetrySession')->info("Evicting oldest session (max instance reached): {$oldestKey}"); + if (isset(self::$instances[$oldestKey])) { + self::$instances[$oldestKey]->close(); + } + unset(self::$instances[$oldestKey]); + unset(self::$instanceTimestamps[$oldestKey]); + } + } + + /** + * Synchronize settings with broker via telemetry stream. + * + * @param object $settingsCommand Telemetry command containing settings + * @return bool True if settings were successfully synced + */ + public function syncSettings($settingsCommand) + { + $this->lastSettingsCommand = $settingsCommand; + $this->isClosing = false; + + // Create stream and send settings + $success = $this->createStreamAndSync($settingsCommand); + if (!$success) { + return false; + } + + // Wait for settings confirmation with timeout + return $this->waitForSettingsConfirmation(); + } + + /** + * Wait for settings confirmation from broker with timeout. + * In Swoole mode, the background reader will set settingsSynced when SETTINGS is received. + * In non-Swoole mode, we poll manually with exponential backoff. + * + * @return bool True if settings confirmed before timeout + */ + private function waitForSettingsConfirmation(): bool + { + $startTime = microtime(true); + $pollIntervalUs = 10000; + $maxPollIntervalUs = 200000; + + while (microtime(true) - $startTime < $this->settingsTimeout) { + if ($this->settingsSynced) { + $elapsed = round(microtime(true) - $startTime, 2); + $this->logger->info("Settings confirmed by broker after {$elapsed}s"); + return true; + } + + if ($this->settingsError !== null) { + $this->logger->error("Settings stream error: " . $this->settingsError); + return false; + } + + // In non-Swoole mode or outside coroutine, poll for responses + if (!SwooleCompat::inCoroutine()) { + $this->pollTelemetryManual(); + } + + SwooleCompat::sleep($pollIntervalUs); + $pollIntervalUs = min($pollIntervalUs * 2, $maxPollIntervalUs); + } + + // Timeout + $this->logger->error("Settings confirmation not received within {$this->settingsTimeout}s"); + return false; + } + + /** + * Manual poll for telemetry responses (non-Swoole mode). + * This is a blocking call that reads one response at a time. + * + * @return void + */ + private function pollTelemetryManual(): void + { + if (!$this->stream) { + return; + } + + try { + // Try to read with a very short timeout + // Note: gRPC PHP doesn't support non-blocking read easily, + // so we just check if there's data available + $response = $this->stream->read(); + if ($response !== null) { + $this->handleResponse($response); + } + } catch (\Exception $e) { + // Ignore read errors during polling + $this->logger->debug("Poll read error: " . $e->getMessage()); + } + } + + /** + * Create telemetry stream and send settings command. + * + * @param object $settingsCommand Telemetry command containing settings + * @return bool True on success + */ + public function createStreamAndSync($settingsCommand) + { + try { + $this->logger->info("Creating telemetry stream..."); + + if (empty($this->namespace) && $settingsCommand->hasSettings()) { + // Extract namespace from settings subscription group if not already set + $settings = $settingsCommand->getSettings(); + if ($settings->hasSubscription()) { + $subscription = $settings->getSubscription(); + if ($subscription->hasGroup()) { + $group = $subscription->getGroup(); + try { + $ns = $group->getResourceNamespace(); + if (!empty($ns)) { + $this->namespace = $ns; + $this->logger->info("Extracted namespace from settings command: {$ns}"); + } + } catch (\Throwable $e) { + // resourceNamespace not available in this protobuf version + } + } + } + } + + $clientId = $this->clientId ?: $this->getClientIdFromCommand($settingsCommand); + $metadata = Signature::sign( + $this->credentials, + $clientId, + ClientConstants::LANGUAGE, + ClientConstants::CLIENT_VERSION, + $this->namespace, + 'v2' + ); + + $this->stream = $this->client->Telemetry($metadata); + $this->logger->info("Stream created successfully"); + + // Start background reader + $this->startBackgroundReader(); + + // Send Settings command + $this->logger->info("Sending settings command..."); + $success = $this->writeSync($settingsCommand); + + if (!$success) { + throw new \RuntimeException("Failed to send settings command"); + } + + $this->logger->info("Settings sent successfully, waiting for broker confirmation (timeout: {$this->settingsTimeout}s)..."); + // Don't set settingsSynced here - wait for confirmation from broker + // The settingsSynced flag will be set in handleResponse() when we receive SETTINGS from broker + + return true; + + } catch (\Exception $e) { + $this->logger->error("Failed to establish and sync settings: " . $e->getMessage()); + if (!$this->isClosing) { + $this->scheduleReconnect($settingsCommand); + } + return false; + } + } + + /** + * Start background reader. With Swoole, runs in a coroutine. + * Without Swoole, the reader must be invoked manually via pollTelemetry(). + * + * @return void + */ + private function startBackgroundReader() + { + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + $self = $this; + \Swoole\Coroutine::create(function () use ($self) { + $self->swooleCoroutineId = \Swoole\Coroutine::getCid(); + $self->logger->info("Swoole background reader started (coroutine ID: {$self->swooleCoroutineId})"); + $self->readResponsesInBackground(); + $self->swooleCoroutineId = -1; + $self->logger->info("Swoole background reader stopped"); + }); + } else { + $this->logger->info("Background reader will be invoked via pollTelemetry() in main loop"); + } + } + + /** + * Poll for telemetry responses (non-Swoole fallback). + * Call this from the client's main loop to process server-pushed commands. + * Note: This is a blocking call that reads one response at a time. + * + * @return void + */ + public function pollTelemetry(): void + { + if (!$this->stream) { + return; + } + + if (SwooleCompat::isAvailable()) { + // In Swoole mode, background reader handles it + return; + } + + // In non-Swoole mode, manually poll + $this->pollTelemetryManual(); + } + + /** + * Continuously read and handle telemetry responses in a loop. + * + * Optimizations over a row while(true): + * - Checks isClosing flag for graceful shutdown + * - Yields to the Swoole scheduler each iteration to prevent starvation + * - Applies timeout protection on each stream read (Swoole mode) + * - Tracks consecutive read errors and aborts after MAX_CONSECUTIVE_ERRORS + * @return void + */ + private function readResponsesInBackground() + { + if (!$this->stream) { + $this->logger->warning("No stream available for reading"); + return; + } + + $consecutiveErrors = 0; + $this->logger->debug("Background reader started, listening for responses..."); + + while (true) { + // exit condition: session is closing + if ($this->isClosing) { + $this->logger->info("Background reader exiting : session closure"); + break; + } + + if (!$this->stream) { + $this->logger->warning("Stream closed during background reading"); + break; + } + try { + // Read with timeout protection in Swoole mode + $response = $this->readWithTimeout(); + if (SwooleCompat::isAvailable()) { + \Swoole\Coroutine::sleep(0); + } + if ($this->isClosing) { + $this->logger->info("Background reader exiting : session is closing (post-read)"); + break; + } + if ($response === null) { + $this->logger->debug("Stream closed during background reading, stream ended"); + break; + } + $consecutiveErrors = 0; + $this->handleResponse($response); + } catch (\Throwable $e) { + $consecutiveErrors++; + $this->logger->error("Error in background reader (#{$consecutiveErrors}) : " . $e->getMessage()); + if (!$this->settingsSynced) { + $this->settingsError = $e->getMessage(); + } + if ($consecutiveErrors >= self::MAX_CONSECUTIVE_ERRORS) { + $this->logger->warning("Exceeded max consecutive errors (" . self::MAX_CONSECUTIVE_ERRORS . "), aborting background reader"); + break; + } + SwooleCompat::sleep(100000); + } + } + $this->logger->debug("Background reader finished"); + if (!$this->isClosing && $this->lastSettingsCommand !== null) { + $this->logger->warning("Telemetry stream lost, attempting reconnection"); + $this->scheduleReconnect($this->lastSettingsCommand); + } + } + + /** + * Read a single response from the stream with timeout protection. + * + * In Swoole coroutine mode, the read is wrapped in a chel-based. + * timeout so a hung stream cannot block the coroutine forever. + * In non-Swoole mode, the read is performed manually with a timeout. + * + * @return mixed Response object, or null on stream end + */ + private function readWithTimeout() + { + if (!SwooleCompat::isAvailable() || !SwooleCompat::inCoroutine()) { + return $this->stream->read(); + } + + // Swoole coroutine mode: use a channel to enforce read timeout + $channel = new \Swoole\Coroutine\Channel(1); + $stream = $this->stream; + + \Swoole\Coroutine::create(function () use ($channel, $stream) { + try { + $result = $stream->read(); + $channel->push(['status' => 'ok', 'data' => $result]); + } catch (\Throwable $e) { + $channel->push(['status' => 'error', 'exception' => $e]); + } + }); + $result = $channel->pop(self::READ_TIMEOUT_SECONDS); + if ($result === false) { + $this->logger->error("Stream timeout while ". self::READ_TIMEOUT_SECONDS); + return null; + } + if ($result['status'] === 'error') { + throw $result['exception']; + } + return $result['data']; + } + + /** + * Dispatch a telemetry command to the appropriate handler. + * + * @param object $command Telemetry command from broker + * @return void + */ + private function handleResponse($command) + { + $this->logger->info("Received command from broker"); + + if ($command->hasSettings()) { + $settings = $command->getSettings(); + $this->logger->info("Received SETTINGS command from broker"); + + $this->serverSettings = $settings; + + if ($this->onSettingsChange !== null) { + try { + ($this->onSettingsChange)($settings); + } catch (\Exception $e) { + $this->logger->error("Settings change callback failed: " . $e->getMessage()); + } + } + + $this->settingsSynced = true; + + if ($settings->hasClientType()) { + $this->logger->debug(" ClientType: " . $settings->getClientType()); + } + } elseif ($command->hasStatus()) { + $status = $command->getStatus(); + $this->logger->info("Received STATUS command: Code=" . $status->getCode()); + } elseif ($command->hasRecoverOrphanedTransactionCommand()) { + $recoverCmd = $command->getRecoverOrphanedTransactionCommand(); + $this->logger->info("Received RecoverOrphanedTransactionCommand: transactionId=" . $recoverCmd->getTransactionId()); + + if ($this->onRecoverOrphanedTransaction !== null) { + try { + ($this->onRecoverOrphanedTransaction)($recoverCmd); + } catch (\Exception $e) { + $this->logger->error("RecoverOrphanedTransaction callback failed: " . $e->getMessage()); + } + } + } elseif ($command->hasVerifyMessageCommand()) { + $verifyCmd = $command->getVerifyMessageCommand(); + $this->logger->info("Received VerifyMessageCommand: nonce=" . $verifyCmd->getNonce()); + + if ($this->onVerifyMessage !== null) { + try { + $response = ($this->onVerifyMessage)($verifyCmd); + if ($response instanceof TelemetryCommand) { + $this->writeSync($response); + } + } catch (\Exception $e) { + $this->logger->error("VerifyMessage callback failed: " . $e->getMessage()); + } + } + } elseif ($command->hasPrintThreadStackTraceCommand()) { + $printCmd = $command->getPrintThreadStackTraceCommand(); + $this->logger->info("Received PrintThreadStackTraceCommand: nonce=" . $printCmd->getNonce()); + + if ($this->onPrintThreadStackTrace !== null) { + try { + $response = ($this->onPrintThreadStackTrace)($printCmd); + if ($response instanceof TelemetryCommand) { + $this->writeSync($response); + } + } catch (\Exception $e) { + $this->logger->error("PrintThreadStackTrace callback failed: " . $e->getMessage()); + } + } + } elseif ($command->hasReconnectEndpointsCommand()) { + $reconnectCmd = $command->getReconnectEndpointsCommand(); + $this->logger->info("Received ReconnectEndpointsCommand: nonce=" . $reconnectCmd->getNonce()); + + if ($this->onReconnectEndpoints !== null) { + try { + ($this->onReconnectEndpoints)($reconnectCmd); + } catch (\Exception $e) { + $this->logger->error("ReconnectEndpoints callback failed: " . $e->getMessage()); + } + } + } elseif ($command->hasNotifyUnsubscribeLiteCommand()) { + $notifyCmd = $command->getNotifyUnsubscribeLiteCommand(); + $this->logger->info("Received NotifyUnsubscribeLiteCommand: liteTopic=" . $notifyCmd->getLiteTopic()); + + if ($this->onNotifyUnsubscribeLite !== null) { + try { + ($this->onNotifyUnsubscribeLite)($notifyCmd); + } catch (\Exception $e) { + $this->logger->error("NotifyUnsubscribeLite callback failed: " . $e->getMessage()); + } + } + } else { + $this->logger->debug("Received unrecognized command"); + } + } + + /** + * Write a telemetry command to the stream synchronously. + * + * @param object $command Telemetry command to send + * @return bool True on success + */ + public function writeSync($command) + { + try { + if (!$this->stream) { + $this->logger->error("Stream not initialized"); + return false; + } + + $serialized = $command->serializeToString(); + if ($serialized === false || strlen($serialized) === 0) { + $this->logger->error("Serialization failed"); + return false; + } + + $result = $this->stream->write($command); + + if ($result === false) { + $this->logger->error("write() returned false"); + return false; + } + + try { + $this->stream->flush(); + } catch (\Throwable $e) { + // flush not supported or failed, non-fatal + } + + return true; + + } catch (\Exception $e) { + $this->logger->error("writeSync failed: " . $e->getMessage()); + return false; + } + } + + /** + * Close the telemetry session and remove from instance pool. + * Sets the closing flag to prevent reconnection, canceling the stream, and removing from instance pool. + * + * + * @param float $timeoutSec Timeout in seconds + * @return void + */ + public function close(float $timeoutSec = 3.0) + { + $this->logger->info("Closing session..."); + // Prevent reconnection attempts from background reader + $this->isClosing = true; + // Cancel Swoole background reader coroutine if running + if ($this->swooleCoroutineId > 0 && SwooleCompat::isAvailable()) { + try { + \Swoole\Coroutine::cancel($this->swooleCoroutineId); + $this->logger->info("Cancelled Swoole background reader coroutine (ID : {$this->swooleCoroutineId})"); + } catch (\Throwable $e) { + $this->logger->warning("Error cancelling Swoole background reader coroutine (ID : {$this->swooleCoroutineId}): " . $e->getMessage()); + } + $this->swooleCoroutineId = -1; + } + // Close the gRPC stream with timeout protection + + if ($this->stream) { + // Signal that we're done writing + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + // Swoole coroutine context: use channel-based timeout + $channel = new \Swoole\Coroutine\Channel(1); + $stream = $this->stream; + $logger = $this->logger; + \Swoole\Coroutine::create(function () use ($channel, $stream, $logger) { + try { + try { + $stream->writesDone(); + } catch (\Throwable $e) { + // writesDone not supported, skip + } + $stream->cancel(); + $channel->push(true); + } catch (\Throwable $e) { + $logger->error("Error closing stream coroutine: " . $e->getMessage()); + $channel->push(false); + } + }); + $result = $channel->pop($timeoutSec); + if ($result === false) { + $this->logger->warning("Session close timed out after {$timeoutSec}s, forcing cleanup"); + try { + $this->stream->cancel(); + } catch (\Throwable $e) { + + } + } + } else { + // Non-Swoole context: call directly(cancel is typically non-blocking) + try { + try { + $this->stream->writesDone(); + } catch (\Throwable $e) { + // writesDone not supported, skip + } + $this->stream->cancel(); + } catch (\Throwable $e) { + $this->logger->error("Error closing stream: " . $e->getMessage()); + } + } + } + + $credId = $this->credentials !== null ? spl_object_id($this->credentials) : 'none'; + $effectiveClientId = $this->clientId ?? 'none'; + $key = $this->endpoints . '|' . $credId . '|' . $this->namespace . "|" . $effectiveClientId; + unset(self::$instances[$key]); + unset(self::$instanceTimestamps[$key]); + + $this->stream = null; + $this->logger->info("Session closed"); + } + + /** + * Get the client identifier. + * + * @return string + */ + public function getClientId() + { + return $this->clientId; + } + + /** + * Check if settings have been synced with broker. + * + * @return bool + */ + public function isSettingsSynced() + { + return $this->settingsSynced; + } + + /** + * Get the settings error message if sync failed. + * + * @return string|null + */ + public function getSettingsError() + { + return $this->settingsError; + } + + /** + * Generate a client ID from the current process and time. + * + * @param object $command Telemetry command + * @return string Generated client identifier + */ + private function getClientIdFromCommand($command) + { + return 'php-client-' . getmypid() . '-' . time(); + } + + /** + * Schedule reconnection after stream loss. + * + * @param object $settingsCommand Settings command to resend on reconnect + * @return void + */ + private function scheduleReconnect($settingsCommand) + { + if ($this->isClosing || $this->isReconnecting) { + $this->logger->debug("Skipping telemetry reconnection : closing=" . $this->isClosing . ", reconnecting=" . $this->isReconnecting); + return; + } + $this->isReconnecting = true; + $this->logger->info("Scheduling telemetry reconnection in 1 second.."); + if (SwooleCompat::isAvailable() && SwooleCompat::inCoroutine()) { + $self = $this; + \Swoole\Coroutine::create(function () use ($self, $settingsCommand) { + \Swoole\Coroutine::sleep(1); + try { + if (!$self->isClosing) { + $self->logger->info("Reconnecting to telemetry.."); + $self->createStreamAndSync($settingsCommand); + } + } finally { + $self->isReconnecting = false; + } + }); + } else { + try { + SwooleCompat::sleep(1000000); + if (!$this->isClosing) { + $this->logger->info("Reconnecting to telemetry.."); + $this->createStreamAndSync($settingsCommand); + } + } finally { + $this->isReconnecting = false; + } + } + } +} diff --git a/php/TlsCredentials.php b/php/TlsCredentials.php new file mode 100644 index 000000000..4a8fe1558 --- /dev/null +++ b/php/TlsCredentials.php @@ -0,0 +1,265 @@ +error( + "CRITICAL SECURITY WARNING: Creating insecure TLS credentials with certificate verification disabled! " . + "This allows man-in-the-middle attacks and compromises connection security. " . + "ONLY use in isolated development/testing environments. " . + "NEVER deploy this in production under any circumstances!" + ); + + return new self( + isInsecure: false, + verifyPeer: false, + verifyPeerName: false + ); + } + + /** + * Convert to Grpc\ChannelCredentials for use with gRPC client. + * + * Note: gRPC PHP extension's createSsl() only accepts 3 optional string parameters: + * createSsl(string|null $pem_root_certs, string|null $private_key, string|null $cert_chain) + * It does NOT support verification options. Peer cert verification is always enabled by gRPC C-core. + * + * @return \Grpc\ChannelCredentials|null Null for insecure connections, ChannelCredentials instance for TLS + * @throws \RuntimeException If certificate file cannot be read + */ + public function toChannelCredentials() + { + if ($this->isInsecure) { + return ChannelCredentials::createInsecure(); + } + + $rootCert = null; + if ($this->caCertPath !== null) { + $rootCert = file_get_contents($this->caCertPath); + } + + $privateKey = null; + $certChain = null; + if ($this->clientCertPath !== null && $this->clientKeyPath !== null) { + $privateKey = file_get_contents($this->clientKeyPath); + $certChain = file_get_contents($this->clientCertPath); + } + + // gRPC createSsl() signature: createSsl(string|null, string|null, string|null) + return ChannelCredentials::createSsl($rootCert, $privateKey, $certChain); + } + + /** + * Get gRPC channel arguments derived from this TLS configuration. + * + * These args should be merged into the opts array when creating the gRPC stub. + * For verifyPeer=false (dev only), sets grpc.ssl_target_name_override to bypass + * hostname verification. Note: gRPC C-core always verifies the certificate itself; + * only the hostname matching can be overridden. + * + * @return array Associative array of gRPC channel args + */ + public function getChannelArgs(string $targetHost = ''): array + { + $args = []; + if (!$this->isInsecure && !$this->verifyPeerName) { + $args['grpc.ssl_target_name_override'] = $targetHost; + $args['grpc.default_authority'] = $targetHost; + } + return $args; + } + + /** + * Check if this is insecure (plaintext) credentials. + * + * @return bool True if insecure, false if TLS is enabled + */ + public function isInsecure(): bool + { + return $this->isInsecure; + } + + /** + * Get the CA certificate path. + * + * @return string|null The CA certificate file path, or null if using system CA + */ + public function getCaCertPath(): ?string + { + return $this->caCertPath; + } + + /** + * Get the client certificate path (for mTLS). + * + * @return string|null The client certificate file path, or null if mTLS is not configured + */ + public function getClientCertPath(): ?string + { + return $this->clientCertPath; + } + + /** + * Get the client key path (for mTLS). + * + * @return string|null The client private key file path, or null if mTLS is not configured + */ + public function getClientKeyPath(): ?string + { + return $this->clientKeyPath; + } + + /** + * Check if peer certificate verification is enabled. + * + * @return bool True if peer certificate is verified, false if verification is skipped + */ + public function shouldVerifyPeer(): bool + { + return $this->verifyPeer; + } + + /** + * Check if peer name verification is enabled. + * + * @return bool True if peer hostname is verified against the certificate, false otherwise + */ + public function shouldVerifyPeerName(): bool + { + return $this->verifyPeerName; + } +} diff --git a/php/Transaction.php b/php/Transaction.php new file mode 100644 index 000000000..6734e545e --- /dev/null +++ b/php/Transaction.php @@ -0,0 +1,210 @@ +committed || $this->rolledBack) { + throw new \RuntimeException("Transaction is already terminated"); + } + + if (!empty($this->messages)) { + throw new \InvalidArgumentException("Transaction only supports one message at a time"); + } + + $this->messages[] = $message; + } + + /** + * Record the send result after the half-message is sent. + * + * @param object $message Message protobuf object + * @param array $sendResult ['messageId' => ..., 'transactionId' => ...] + * @param object|null $endpoints Endpoints for the sent message + * @return void + * @throws \RuntimeException If transaction is terminated or send result is invalid + * @throws \InvalidArgumentException If message is not part of this transaction + */ + public function tryAddReceipt($message, array $sendResult, $endpoints = null): void + { + if ($this->committed || $this->rolledBack) { + throw new \RuntimeException("Transaction is already terminated"); + } + + $messageId = $sendResult['messageId'] ?? ''; + $transactionId = $sendResult['transactionId'] ?? ''; + + if (empty($messageId) || empty($transactionId)) { + throw new \RuntimeException("Invalid send result: messageId and transactionId are required"); + } + + if (!in_array($message, $this->messages, true)) { + throw new \InvalidArgumentException("Message is not part of this transaction"); + } + + $this->receipts[] = [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $message->getTopic()->getName(), + 'endpoints' => $endpoints, + ]; + } + + /** + * Alias for tryAddReceipt. + * + * @param object $message Message protobuf object + * @param array $sendResult ['messageId' => ..., 'transactionId' => ...] + * @return void + * @throws \RuntimeException If transaction is terminated or send result invalid + * @throws \InvalidArgumentException If message is not part of this transaction + */ + public function addReceipt($message, array $sendResult): void + { + $this->tryAddReceipt($message, $sendResult); + } + + /** + * Commit all half-messages in this transaction. + * + * @return void + * @throws \RuntimeException If transaction is already terminated or has no receipts + */ + public function commit(): void + { + if ($this->committed || $this->rolledBack) { + throw new \RuntimeException("Transaction is already terminated"); + } + + if (empty($this->receipts)) { + throw new \RuntimeException("No receipts to commit"); + } + + foreach ($this->receipts as $receipt) { + $this->committer->commitTransaction( + $receipt['messageId'], + $receipt['transactionId'], + $receipt['topic'], + $receipt['endpoints'] ?? null + ); + } + + $this->committed = true; + $this->messages = []; + $this->receipts = []; + } + + /** + * Rollback all half-messages in this transaction. + * + * @return void + * @throws \RuntimeException If transaction is already terminated or has no receipts + */ + public function rollback(): void + { + if ($this->committed || $this->rolledBack) { + throw new \RuntimeException("Transaction is already terminated"); + } + + if (empty($this->receipts)) { + throw new \RuntimeException("No receipts to rollback"); + } + + foreach ($this->receipts as $receipt) { + $this->committer->rollbackTransaction( + $receipt['messageId'], + $receipt['transactionId'], + $receipt['topic'], + $receipt['endpoints'] ?? null + ); + } + + $this->rolledBack = true; + $this->messages = []; + $this->receipts = []; + } + + /** + * Get tracked messages. + * + * @return array List of tracked messages + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * Get tracked receipts. + * + * @return array List of tracked receipts + */ + public function getReceipts(): array + { + return $this->receipts; + } + + /** + * Check if this transaction has been committed. + * + * @return bool True if committed, false otherwise + */ + public function isCommitted(): bool + { + return $this->committed; + } + + /** + * Check if this transaction has been rolled back. + * + * @return bool True if rolled back, false otherwise + */ + public function isRolledBack(): bool + { + return $this->rolledBack; + } +} diff --git a/php/TransactionChecker.php b/php/TransactionChecker.php new file mode 100644 index 000000000..d59a0e4b8 --- /dev/null +++ b/php/TransactionChecker.php @@ -0,0 +1,39 @@ +transactionChecker = $checker; + return $this; + } + + /** + * Set local transaction executer for auto commit/rollback of half-messages. + */ + public function setLocalTransactionExecuter(LocalTransactionExecuter $executer): self + { + $this->localTransactionExecuter = $executer; + return $this; + } + + /** + * Send a transaction message (half-message + local transaction + commit/rollback). + */ + public function sendWithTransaction(Message $message, Transaction $transaction, ?LocalTransactionExecuter $executor = null): array + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + + $this->validateMessage($message); + + $sysProps = $message->getSystemProperties(); + $hasMessageGroup = $sysProps && $sysProps->hasMessageGroup(); + $hasLiteTopic = $sysProps && $sysProps->hasLiteTopic(); + $hasDeliveryTimestamp = $sysProps && $sysProps->hasDeliveryTimestamp(); + $hasPriority = $sysProps && $sysProps->hasPriority(); + + if ($hasMessageGroup || $hasLiteTopic || $hasDeliveryTimestamp || $hasPriority) { + throw new \InvalidArgumentException( + "Transactional message should not set messageGroup, deliveryTimestamp, liteTopic, or priority" + ); + } + + $topic = $message->getTopic()->getName(); + $loadBalancer = $this->getPublishingLoadBalancer($topic); + $messageQueue = $loadBalancer->takeMessageQueue($this->getIsolatedBrokerNames(), $this->getSettingsMaxAttempts()); + + if (empty($messageQueue)) { + throw new \RuntimeException("No available message queue for topic: {$topic}"); + } + + if ($this->validator->isValidateMessageType()) { + $msgType = $this->detectMessageType($message, true); + $loadBalancer->validateMessageTypeAgainstQueue($messageQueue[0], $msgType, $topic); + } + + $request = $this->wrapTransactionMessageRequest([$message], $messageQueue[0]); + // txEnabled=true so retries rebuild the request as a transaction (half) message + $result = $this->sendMessageWithRetry($request, $message, $messageQueue, $this->getSettingsMaxAttempts(), true); + + if (isset($result['transactionId'])) { + $transaction->tryAddMessage($message); + // Track the endpoint of the queue that actually succeeded (retries may + // rotate away from $messageQueue[0]) + $successEndpoints = $result['endpoints'] + ?? PublishingRouteManager::extractMessageQueueEndpoint($messageQueue[0]); + $transaction->tryAddReceipt($message, $result, $successEndpoints); + } + + $resolvedExecutor = $executor ?? $this->localTransactionExecuter; + if ($resolvedExecutor !== null) { + $messageView = new MessageView($message, $result['recallHandle'] ?? null, null, 1); + $resolution = $resolvedExecutor->execute($messageView); + + if ($resolution === TransactionResolution::COMMIT) { + $transaction->commit(); + } elseif ($resolution === TransactionResolution::ROLLBACK) { + $transaction->rollback(); + } + } + + return $result; + } + + /** + * Begin a new transaction. + */ + public function beginTransaction(): Transaction + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + + if ($this->transactionChecker === null) { + throw new \RuntimeException("Transaction checker should not be null. Please set TransactionChecker the Producer."); + } + return new Transaction($this); + } + + /** + * Commit a transaction by messageId and transactionId. + */ + public function commitTransaction(string $messageId, string $transactionId, string $topic, ?Endpoints $endpoints = null): void + { + $this->endTransaction($messageId, $transactionId, $topic, TransactionResolution::COMMIT, $endpoints); + } + + /** + * Rollback a transaction by messageId and transactionId. + */ + public function rollbackTransaction(string $messageId, string $transactionId, string $topic, ?Endpoints $endpoints = null): void + { + $this->endTransaction($messageId, $transactionId, $topic, TransactionResolution::ROLLBACK, $endpoints); + } + + /** + * End (commit or rollback) a transaction via gRPC. + */ + private function endTransaction(string $messageId, string $transactionId, string $topic, int $resolution, ?Endpoints $endpoints = null, int $source = TransactionSource::SOURCE_CLIENT): void + { + if (!$this->isRunning) { + throw new \RuntimeException("Producer is not running now"); + } + + $hookPoint = match ($resolution) { + TransactionResolution::COMMIT => MessageHookPoints::COMMIT_TRANSACTION, + TransactionResolution::ROLLBACK => MessageHookPoints::ROLLBACK_TRANSACTION, + default => MessageHookPoints::COMMIT_TRANSACTION, + }; + + $this->executeInterceptors($hookPoint, [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $topic, + ]); + + $topicResource = new Resource(); + $topicResource->setName($topic); + + $request = new EndTransactionRequest(); + $request->setMessageId($messageId); + $request->setTransactionId($transactionId); + $request->setTopic($topicResource); + $request->setResolution($resolution); + $request->setSource($source); + + $timeoutMs = (int)($this->getOperationTimeout('END_TRANSACTION') / 1000); + $metadata = $this->buildMetadata($timeoutMs); + $callOptions = ['timeout' => $this->getOperationTimeout('END_TRANSACTION')]; + + if ($endpoints !== null) { + $address = $endpoints->getAddresses(); + if (!empty($address) && $address[0] !== null) { + $brokerKey = $address[0]->getHost() . ':' . $address[0]->getPort(); + $brokerClient = RpcClientManager::getInstance()->getClient($brokerKey, [ + 'tlsCredentials' => $this->getSettingsTlsCredentials(), + 'sslEnabled' => $this->isSettingsSslEnabled(), + ]); + list($response, $status) = $brokerClient->EndTransaction($request, $metadata, $callOptions)->wait(); + } else { + list($response, $status) = $this->getClientForRpc()->EndTransaction($request, $metadata, $callOptions)->wait(); + } + } else { + list($response, $status) = $this->getClientForRpc()->EndTransaction($request, $metadata, $callOptions)->wait(); + } + + if ($status->code !== 0) { + throw new \RuntimeException("End transaction failed: " . $status->details); + } + + if ($response->hasStatus()) { + $statusCode = $response->getStatus()->getCode(); + if ($statusCode !== 20000) { + throw new \RuntimeException("End transaction failed with code: " . $statusCode); + } + } + } + + /** + * Register TransactionChecker callback on TelemetrySession. + */ + private function registerTransactionCheckerCallback(): void + { + if ($this->transactionChecker === null) { + return; + } + + $self = $this; + $this->getTelemetrySession()->setOnRecoverOrphanedTransaction(function ($command) use ($self) { + $self->handleOrphanedTransaction($command); + }); + } + + /** + * Handle an orphaned transaction command from the server. + */ + private function handleOrphanedTransaction(object $command): void + { + if ($this->transactionChecker === null) { + $this->getLogger()->warning("Received orphaned transaction command but no TransactionChecker registered"); + return; + } + + try { + $message = null; + try { + $message = $command->getMessage(); + } catch (\Throwable $e) { + // getMessage not available + } + + if ($message === null) { + $this->getLogger()->warning("Orphaned transaction command has no message"); + return; + } + + $messageView = new MessageView($message, null, null, 1); + $resolution = $this->transactionChecker->check($messageView); + + if ($resolution === null || $resolution === TransactionResolution::TRANSACTION_RESOLUTION_UNSPECIFIED) { + $this->getLogger()->debug("Transaction checker returned TRANSACTION_RESOLUTION_UNSPECIFIED, leaving transaction unresolved."); + return; + } + + $transactionId = $command->getTransactionId() ?? ''; + + $messageId = ''; + $topicName = ''; + $sysProps = $message->getSystemProperties(); + if ($sysProps !== null) { + $messageId = $sysProps->getMessageId() ?? ''; + } + if ($message->hasTopic()) { + $topicName = $message->getTopic()->getName(); + } + + // Note: Message protobuf does not have getEndpoints(); + // endpoint routing is handled by the broker-side recovery mechanism. + if (!empty($messageId) && !empty($topicName)) { + $this->endTransaction($messageId, $transactionId, $topicName, $resolution, null, TransactionSource::SOURCE_SERVER_CHECK); + } + } catch (\Exception $e) { + $this->getLogger()->error("TransactionChecker threw exception: " . $e->getMessage()); + } + } +} diff --git a/php/Utilities.php b/php/Utilities.php new file mode 100644 index 000000000..675fe1e34 --- /dev/null +++ b/php/Utilities.php @@ -0,0 +1,235 @@ + self::ENCODING_GZIP, + default => self::ENCODING_IDENTITY, + }; + } + + /** + * Compress data with the specified encoding. + * + * @param string $data Raw data to compress + * @param string $encoding One of ENCODING_GZIP_STR, ENCODING_ZLIB_STR, ENCODING_ZSTD_STR, ENCODING_LZ4_STR + * @return string Compressed data + * @throws \RuntimeException if the required extension is not installed + */ + public static function compressBytes(string $data, string $encoding): string + { + switch ($encoding) { + case self::ENCODING_GZIP_STR: + case self::ENCODING_GZIP: + $result = gzencode($data, 5); + break; + case self::ENCODING_ZLIB_STR: + case self::ENCODING_ZLIB: + $result = gzcompress($data, 5); + break; + case self::ENCODING_ZSTD_STR: + if (!function_exists('zstd_compress')) { + throw new \RuntimeException("ZSTD compression requires the zstd PHP extension"); + } + $result = zstd_compress($data, 5); + break; + case self::ENCODING_LZ4_STR: + if (!function_exists('lz4_compress')) { + throw new \RuntimeException("LZ4 compression requires the lz4 PHP extension"); + } + $result = lz4_compress($data); + break; + default: + throw new \InvalidArgumentException("Unsupported encoding: {$encoding}"); + } + + if ($result === false) { + throw new \RuntimeException("Compression failed for encoding: {$encoding}"); + } + + return $result; + } + + /** + * Decompress data. If encoding is IDENTITY or UNSPECIFIED, auto-detects via magic bytes. + * + * @param string $data Compressed data + * @param string|int|null $encoding Encoding constant or name. null = auto-detect. + * @return string Decompressed data + * @throws \RuntimeException if decompression fails or encoding is unsupported + */ + public static function decompressBytes(string $data, $encoding = null): string + { + if ($encoding === null || $encoding === self::ENCODING_UNSPECIFIED || $encoding === self::ENCODING_IDENTITY_STR || $encoding === self::ENCODING_IDENTITY) { + $encoding = self::detectEncoding($data); + if ($encoding === self::ENCODING_IDENTITY) { + return $data; + } + } + + switch ($encoding) { + case self::ENCODING_GZIP: + case self::ENCODING_GZIP_STR: + $result = gzdecode($data); + break; + case self::ENCODING_ZLIB: + case self::ENCODING_ZLIB_STR: + $result = gzuncompress($data); + break; + case self::ENCODING_ZSTD: + case self::ENCODING_ZSTD_STR: + if (!function_exists('zstd_uncompress')) { + throw new \RuntimeException("ZSTD decompression requires the zstd PHP extension"); + } + $result = zstd_uncompress($data); + break; + case self::ENCODING_LZ4: + case self::ENCODING_LZ4_STR: + if (!function_exists('lz4_uncompress')) { + throw new \RuntimeException("LZ4 decompression requires the lz4 PHP extension"); + } + $result = lz4_uncompress($data); + break; + default: + throw new \InvalidArgumentException("Unsupported encoding: {$encoding}"); + } + + if ($result === false) { + throw new \RuntimeException("Decompression failed for encoding: {$encoding}"); + } + + return $result; + } + + /** + * Auto-detect compression encoding from magic bytes. + * + * @param string $data Compressed data + * @return int Encoding constant (IDENTITY if no compression detected) + */ + private static function detectEncoding(string $data): int + { + if (strlen($data) < 2) { + return self::ENCODING_IDENTITY; + } + + $prefix2 = substr($data, 0, 2); + $prefix4 = substr($data, 0, 4); + + if ($prefix2 === self::MAGIC_GZIP) { + return self::ENCODING_GZIP; + } + if ($prefix4 === self::MAGIC_ZSTD) { + return self::ENCODING_ZSTD; + } + if ($prefix4 === self::MAGIC_LZ4) { + return self::ENCODING_LZ4; + } + if ($prefix2[0] === self::MAGIC_ZLIB_DEFLATE) { + return self::ENCODING_ZLIB; + } + + return self::ENCODING_IDENTITY; + } + + /** + * Compute CRC32 checksum and return as zero-padded uppercase hex string. + * + * @param string $data Input data to checksum + * @return string Uppercase zero-padded hex checksum + */ + public static function crc32CheckSum(string $data): string + { + return strtoupper(sprintf('%08X', crc32($data))); + } + + /** + * Compute MD5 checksum and return as uppercase hex string. + * + * @param string $data Input data to checksum + * @return string Uppercase hex checksum + */ + public static function md5CheckSum(string $data): string + { + return strtoupper(md5($data)); + } + + /** + * Compute SHA1 checksum and return as uppercase hex string. + * + * @param string $data Input data to checksum + * @return string Uppercase hex checksum + */ + public static function sha1CheckSum(string $data): string + { + return strtoupper(sha1($data)); + } + + /** + * Encode binary data as uppercase hexadecimal string. + * + * @param string $bytes Binary data to encode + * @return string Uppercase hex string + */ + public static function encodeHexString(string $bytes): string + { + return strtoupper(bin2hex($bytes)); + } +} diff --git a/php/autoload.php b/php/autoload.php new file mode 100644 index 000000000..a7d6cc381 --- /dev/null +++ b/php/autoload.php @@ -0,0 +1,118 @@ + tags only set PHP $_ENV, not the process environment. +// Setting them via putenv ensures the C library can read them. +if (!getenv('GRPC_VERBOSITY')) { + putenv('GRPC_VERBOSITY=ERROR'); + $_ENV['GRPC_VERBOSITY'] = 'ERROR'; +} +if (!getenv('GRPC_TRACE')) { + putenv('GRPC_TRACE=none'); + $_ENV['GRPC_TRACE'] = 'none'; +} + +// Also attempt ini-level suppression if the gRPC extension supports it. +@ini_set('grpc.grpc_verbosity', 'ERROR'); +@ini_set('grpc.grpc_trace', 'none'); + +spl_autoload_register(function (string $class): void { + $baseDir = __DIR__; + $grpcDir = __DIR__ . '/grpc/'; + + // Apache\Rocketmq\* -> current directory (excluding V2 which is in grpc/) + $prefix = 'Apache\\Rocketmq\\'; + $prefixLen = strlen($prefix); + if (strncmp($class, $prefix, $prefixLen) === 0) { + $relativeClass = substr($class, $prefixLen); + // Skip V2 classes - they are handled separately below + if (strpos($relativeClass, 'V2\\') !== 0) { + $file = $baseDir . '/' . str_replace('\\', '/', $relativeClass) . '.php'; + if (is_file($file)) { + require_once $file; + return; + } + } + } + + // Apache\Rocketmq\V2\* -> grpc/Apache/Rocketmq/V2/ + $prefix = 'Apache\\Rocketmq\\V2\\'; + $prefixLen = strlen($prefix); + if (strncmp($class, $prefix, $prefixLen) === 0) { + $relativeClass = str_replace('\\', '/', substr($class, $prefixLen)); + $file = $grpcDir . 'Apache/Rocketmq/V2/' . $relativeClass . '.php'; + if (is_file($file)) { + require_once $file; + return; + } + } + + // GPBMetadata\* -> grpc/GPBMetadata/ + $prefix2 = 'GPBMetadata\\'; + $prefixLen2 = strlen($prefix2); + if (strncmp($class, $prefix2, $prefixLen2) === 0) { + $relativeClass = str_replace('\\', '/', substr($class, $prefixLen2)); + $file = $grpcDir . 'GPBMetadata/' . $relativeClass . '.php'; + if (is_file($file)) { + require_once $file; + return; + } + } + + // Google\Protobuf\* -> vendor/google/protobuf/src/Google/Protobuf/ + // (well-known types like Timestamp, Duration) + $prefix3 = 'Google\\Protobuf\\'; + $prefixLen3 = strlen($prefix3); + if (strncmp($class, $prefix3, $prefixLen3) === 0) { + $relativeClass = str_replace('\\', '/', substr($class, $prefixLen3)); + $file = __DIR__ . '/vendor/google/protobuf/src/Google/Protobuf/' . $relativeClass . '.php'; + if (is_file($file)) { + require_once $file; + return; + } + } + + // GPBMetadata\Google\Protobuf\* -> vendor/google/protobuf/src/GPBMetadata/Google/Protobuf/ + $prefix4 = 'GPBMetadata\\Google\\Protobuf\\'; + $prefixLen4 = strlen($prefix4); + if (strncmp($class, $prefix4, $prefixLen4) === 0) { + $relativeClass = str_replace('\\', '/', substr($class, $prefixLen4)); + $file = __DIR__ . '/vendor/google/protobuf/src/GPBMetadata/Google/Protobuf/' . $relativeClass . '.php'; + if (is_file($file)) { + require_once $file; + return; + } + } + + // Grpc\* -> vendor/grpc/grpc/src/lib/ + if (strpos($class, 'Grpc\\') === 0) { + $relativeClass = str_replace('\\', '/', substr($class, 5)); + $file = __DIR__ . '/vendor/grpc/grpc/src/lib/' . $relativeClass . '.php'; + if (is_file($file)) { + require_once $file; + return; + } + } +}); diff --git a/php/composer.json b/php/composer.json index c702f3ed8..2225e05d4 100644 --- a/php/composer.json +++ b/php/composer.json @@ -1,16 +1,44 @@ { - "name": "rocketmq/rocketmq-php-sdk", + "name": "rocketmq/rocketmq-client-php", "description": "PHP SDK for Apache RocketMQ", "license": "Apache-2.0", "require": { - "google/protobuf": "^3.3", - "grpc/grpc": "^1.42", - "hanson/foundation-sdk": "^5.0" + "php": ">=8.1", + "google/protobuf": "^3.25", + "grpc/grpc": "^1.57" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "ext-zlib": "*" + }, + "suggest": { + "ext-grpc": "Required for gRPC transport (usually provided by grpc/grpc package)", + "ext-swoole": "Enable Swoole coroutines for async operations (receiveAsync, sendAsync, etc.)", + "ext-openswoole": "Alternative to Swoole for async coroutine support" }, "autoload": { "psr-4": { "GPBMetadata\\": "grpc/GPBMetadata", - "Apache\\Rocketmq\\V2\\": "grpc/Apache/Rocketmq/V2" + "Apache\\Rocketmq\\V2\\": "grpc/Apache/Rocketmq/V2", + "Apache\\Rocketmq\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "config": { + "audit": { + "block-insecure": true, + "ignore": { + "PKSA-tcfz-w4fm-hhk9": "*" + } } + }, + "scripts": { + "test": "phpunit", + "test-coverage": "phpunit --coverage-html coverage", + "test-verbose": "phpunit --verbose" } -} \ No newline at end of file +} diff --git a/php/examples/AsyncProducerExample.php b/php/examples/AsyncProducerExample.php new file mode 100644 index 000000000..b9348a6fa --- /dev/null +++ b/php/examples/AsyncProducerExample.php @@ -0,0 +1,168 @@ +getEndpoints(); +$topic = $config->getTopic('normal'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +// Display configuration +$config->display(); + +echo "\n[INFO] Starting Async Producer with Swoole Coroutines...\n\n"; + +// Check if Swoole is available +if (!class_exists('\Swoole\Coroutine')) { + echo "[ERROR] Swoole extension is not installed!\n"; + echo "Please install Swoole: pecl install swoole\n"; + exit(1); +} + +echo "[INFO] Swoole version: " . SWOOLE_VERSION . "\n"; +echo "[INFO] Sending 5 messages concurrently using coroutines...\n\n"; + +// Use Swoole coroutine context +\Swoole\Coroutine\run(function() use ($endpoints, $topic, $credentials, $sslEnabled) { + // Create producer + $producer = new Producer($endpoints, [ + 'topics' => [$topic], + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'maxAttempts' => 3, + 'requestTimeout' => 3000, + ]); + + $producer->start(); + echo "[SUCCESS] Producer started\n\n"; + + // Track results + $results = []; + $channel = new \Swoole\Coroutine\Channel(10); + + // Send multiple messages concurrently using coroutines + $messageCount = 5; + for ($i = 1; $i <= $messageCount; $i++) { + \Swoole\Coroutine::create(function() use ($producer, $topic, $i, $channel) { + try { + $startTime = microtime(true); + + // Build message + $topicResource = new Resource(); + $topicResource->setName($topic); + + $sysProps = new SystemProperties(); + $sysProps->setTag('async-test'); + $sysProps->setKeys(["async-msg-{$i}"]); + + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody("Async message #{$i} - Sent at " . date('Y-m-d H:i:s')); + $message->setSystemProperties($sysProps); + + echo "[Coroutine " . \Swoole\Coroutine::getCid() . "] Sending message #{$i}...\n"; + + // Send message asynchronously + $result = $producer->send($message); + + $elapsed = round((microtime(true) - $startTime) * 1000, 2); + + echo "[Coroutine " . \Swoole\Coroutine::getCid() . "] ✓ Message #{$i} sent successfully\n"; + echo " Message ID: " . substr($result['messageId'], 0, 30) . "...\n"; + echo " Time: {$elapsed}ms\n\n"; + + $channel->push([ + 'index' => $i, + 'success' => true, + 'messageId' => $result['messageId'], + 'elapsed' => $elapsed, + ]); + + } catch (\Throwable $e) { + echo "[Coroutine " . \Swoole\Coroutine::getCid() . "] ✗ Failed to send message #{$i}: " . $e->getMessage() . "\n\n"; + + $channel->push([ + 'index' => $i, + 'success' => false, + 'error' => $e->getMessage(), + ]); + } + }); + } + + // Collect results from all coroutines + echo "[INFO] Waiting for all coroutines to complete...\n\n"; + + for ($i = 0; $i < $messageCount; $i++) { + $result = $channel->pop(5); // 5 second timeout + if ($result !== false) { + $results[] = $result; + } + } + + // Display summary + echo str_repeat("=", 80) . "\n"; + echo "ASYNC PRODUCER SUMMARY\n"; + echo str_repeat("=", 80) . "\n\n"; + + $successCount = count(array_filter($results, function($r) { return $r['success']; })); + $failCount = $messageCount - $successCount; + + echo "Total messages: {$messageCount}\n"; + echo "Successful: {$successCount}\n"; + echo "Failed: {$failCount}\n\n"; + + if (!empty($results)) { + $avgTime = array_sum(array_column($results, 'elapsed')) / count($results); + echo "Average time: " . round($avgTime, 2) . "ms per message\n"; + } + + echo "\n[NOTE] All messages were sent concurrently using Swoole coroutines\n"; + echo "[NOTE] This provides better throughput than sequential sending\n"; + + echo "\n" . str_repeat("=", 80) . "\n"; + + // Shutdown producer + $producer->shutdown(); + echo "[INFO] Producer shutdown complete\n"; +}); diff --git a/php/examples/AsyncSimpleConsumerExample.php b/php/examples/AsyncSimpleConsumerExample.php new file mode 100644 index 000000000..53df90b57 --- /dev/null +++ b/php/examples/AsyncSimpleConsumerExample.php @@ -0,0 +1,78 @@ +getEndpoints(); +$topic = $config->getTopic('normal'); +$consumerGroup = $config->getConsumerGroup(); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +// Display configuration +$config->display(); + +$consumer = new SimpleConsumer($endpoints, $consumerGroup, [ + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'awaitDuration' => 30, +]); + +$consumer->start(); +$consumer->subscribe($topic); + +echo "Simple consumer started. Receiving messages...\n"; + +$maxMessageNum = 16; +$invisibleDuration = 15; + +while (true) { + try { + $messages = $consumer->receive($maxMessageNum, $invisibleDuration); + if (empty($messages)) { + echo "No messages received, retrying...\n"; + sleep(1); + continue; + } + + echo "Received " . count($messages) . " message(s)\n"; + + foreach ($messages as $msg) { + $body = $msg->getBody() ?? ''; + echo " Received: " . $body . "\n"; + + try { + $consumer->ack($msg); + echo " Acknowledged successfully\n"; + } catch (\Throwable $e) { + echo " Failed to acknowledge: " . $e->getMessage() . "\n"; + } + } + } catch (\Throwable $e) { + echo "Failed to receive message: " . $e->getMessage() . "\n"; + sleep(1); + } +} + +$consumer->shutdown(); diff --git a/php/examples/ExampleConfig.php b/php/examples/ExampleConfig.php new file mode 100644 index 000000000..af94bae67 --- /dev/null +++ b/php/examples/ExampleConfig.php @@ -0,0 +1,285 @@ +getEndpoints(); + * $topic = $config->getTopic('normal'); + * $credentials = $config->getCredentials(); + */ + +use Apache\Rocketmq\TlsCredentials; +use Apache\Rocketmq\SessionCredentials; + +class ExampleConfig +{ + private static ?self $instance = null; + + // Connection settings + private readonly string $endpoints; + private readonly string $namespace; + + // Topic configuration + private readonly array $topics; + + // Consumer configuration + private readonly string $consumerGroup; + private readonly string $tag; + + // Credentials + private readonly ?SessionCredentials $credentials; + + // Lite consumer configuration + private readonly array $liteTopicConfig; + private readonly ?string $tlsCaCert; + private readonly ?string $tlsClientCert; + private readonly ?string $tlsClientKey; + private readonly bool $sslEnabled; + + /** + * Private constructor - use getInstance() instead + */ + private function __construct() + { + // Load from environment variables or use defaults + $this->endpoints = getenv('ROCKETMQ_PHP_CLIENT_ENDPOINTS') ?: '127.0.0.1:8081'; + $this->namespace = getenv('ROCKETMQ_PHP_CLIENT_NAMESPACE') ?: ''; + + // Topic configuration + $this->topics = [ + 'normal' => getenv('ROCKETMQ_PHP_TOPIC_NORMAL') ?: 'TopicTestForNormal', + 'fifo' => getenv('ROCKETMQ_PHP_TOPIC_FIFO') ?: 'FifoTestTopic', + 'delay' => getenv('ROCKETMQ_PHP_TOPIC_DELAY') ?: 'DelayTestTopic', + 'transaction' => getenv('ROCKETMQ_PHP_TOPIC_TRANSACTION') ?: 'TopicTestForTransaction', + 'priority' => getenv('ROCKETMQ_PHP_TOPIC_PRIORITY') ?: 'PriorityTestTopic', + ]; + + // Consumer configuration + $this->consumerGroup = getenv('ROCKETMQ_PHP_CLIENT_GROUP') ?: 'GID_DefaultConsumer'; + $this->tag = getenv('ROCKETMQ_PHP_TAG') ?: '*'; + + // Credentials (optional) + $accessKey = getenv('ROCKETMQ_PHP_CLIENT_KEY') ?: ''; + $secretKey = getenv('ROCKETMQ_PHP_CLIENT_SECRET') ?: ''; + + if (!empty($accessKey) && !empty($secretKey)) { + $this->credentials = new SessionCredentials($accessKey, $secretKey); + } else { + $this->credentials = null; + } + + // Lite topic configuration + $this->liteTopicConfig = [ + 'parentTopic' => getenv('ROCKETMQ_PHP_LITE_PARENT_TOPIC') ?: 'yourParentTopic', + ]; + // TLS configuration + $this->tlsCaCert = getenv('ROCKETMQ_PHP_TLS_CA_CERT') ?: null; + $this->tlsClientCert = getenv('ROCKETMQ_PHP_TLS_CLIENT_CERT') ?: null; + $this->tlsClientKey = getenv('ROCKETMQ_PHP_TLS_CLIENT_KEY') ?: null; + + // SSL enabled (default: true for secure connections) + $sslEnv = getenv('ROCKETMQ_PHP_SSL_ENABLED'); + $this->sslEnabled = $sslEnv !== false ? filter_var($sslEnv, FILTER_VALIDATE_BOOLEAN) : true; + } + + /** + * Get TLS credentials + * @return void + */ + public function getTlsCredentials(): ?TlsCredentials + { + if (!empty($this->tlsClientCert) && !empty($this->tlsClientKey)) { + return TlsCredentials::createMtls($this->tlsClientCert, $this->tlsClientKey, $this->tlsCaCert); + } + + if (!empty($this->tlsCaCert)) { + return TlsCredentials::createWithCa($this->tlsCaCert); + } + return null; + } + + /** + * Get singleton instance + * + * @return ExampleConfig + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Reset instance (useful for testing) + */ + public static function reset(): void + { + self::$instance = null; + } + + // Getters + + /** + * Get endpoints + * + * @return string + */ + public function getEndpoints(): string + { + return $this->endpoints; + } + + /** + * Get namespace + * + * @return string + */ + public function getNamespace(): string + { + return $this->namespace; + } + + /** + * Get topic by type + * + * @param string $type Topic type: normal, fifo, delay, transaction, priority + * @return string + */ + public function getTopic(string $type = 'normal'): string + { + return isset($this->topics[$type]) ? $this->topics[$type] : $this->topics['normal']; + } + + /** + * Get all topics + * + * @return array + */ + public function getTopics(): array + { + return $this->topics; + } + + /** + * Get consumer group + * + * @return string + */ + public function getConsumerGroup(): string + { + return $this->consumerGroup; + } + + /** + * Get tag filter expression + * + * @return string + */ + public function getTag(): string + { + return $this->tag; + } + + /** + * Get credentials (may be null) + * + * @return SessionCredentials|null + */ + public function getCredentials(): ?SessionCredentials + { + return $this->credentials; + } + + /** + * Check if credentials are configured + * + * @return bool + */ + public function hasCredentials(): bool + { + return $this->credentials !== null; + } + + /** + * Get lite topic configuration + * + * @return array + */ + public function getLiteTopicConfig(): array + { + return $this->liteTopicConfig; + } + + /** + * Get lite parent topic + * + * @return string + */ + public function getLiteParentTopic(): string + { + return $this->liteTopicConfig['parentTopic']; + } + + /** + * Get SSL enabled flag + * + * @return bool + */ + public function isSslEnabled(): bool + { + return $this->sslEnabled; + } + + /** + * Display current configuration (for debugging) + */ + public function display(): void + { + echo "========================================\n"; + echo "RocketMQ PHP Client Configuration\n"; + echo "========================================\n"; + echo "Endpoints: {$this->endpoints}\n"; + echo "Namespace: " . ($this->namespace ?: '(empty)') . "\n"; + echo "Consumer Group: {$this->consumerGroup}\n"; + echo "Tag: {$this->tag}\n"; + echo "Credentials: " . ($this->hasCredentials() ? 'Configured' : 'Not configured') . "\n"; + echo "SSL Enabled: " . ($this->sslEnabled ? 'true' : 'false') . "\n"; + echo "\nTopics:\n"; + foreach ($this->topics as $type => $topic) { + echo " {$type}: {$topic}\n"; + } + echo "\nLite Topic:\n"; + echo " Parent Topic: {$this->liteTopicConfig['parentTopic']}\n"; + echo "\nTLS:\n"; + echo " CA Cert: " . ($this->tlsCaCert ? 'Configured' : 'Not configured') . "\n"; + echo " Client Cert: " . ($this->tlsClientCert ? 'Configured' : 'Not configured') . "\n"; + echo " Client Key: " . ($this->tlsClientKey ? 'Configured' : 'Not configured') . "\n"; + echo "========================================\n"; + } +} diff --git a/php/examples/LiteProducerExample.php b/php/examples/LiteProducerExample.php new file mode 100644 index 000000000..62383f8b3 --- /dev/null +++ b/php/examples/LiteProducerExample.php @@ -0,0 +1,64 @@ +getEndpoints(); +$topic = $config->getTopic('priority'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +$producer = new Producer($endpoints, [ + 'topics' => [$topic], + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'maxAttempts' => 3, + 'requestTimeout' => 3000, +]); + +$producer->start(); + +$topicResource = new Resource(); +$topicResource->setName($topic); + +$sysProps = new SystemProperties(); +$sysProps->setKeys(['yourMessageKey-3ee439f945d7']); +// Set your lite topic as a sub-classifier under the parent topic +$sysProps->setLiteTopic('lite-topic-1'); + +$message = new Message(); +$message->setTopic($topicResource); +$message->setBody('This is a lite message for Apache RocketMQ'); +$message->setSystemProperties($sysProps); + +try { + $result = $producer->send($message); + echo "Send message successfully, messageId=" . $result['messageId'] . "\n"; +} catch (\Throwable $e) { + echo "Failed to send message: " . $e->getMessage() . "\n"; +} + +$producer->shutdown(); diff --git a/php/examples/LitePushConsumerExample.php b/php/examples/LitePushConsumerExample.php new file mode 100644 index 000000000..592846406 --- /dev/null +++ b/php/examples/LitePushConsumerExample.php @@ -0,0 +1,86 @@ +getEndpoints(); +$consumerGroup = $config->getConsumerGroup(); +$parentTopic = $config->getLiteParentTopic(); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +$consumer = new LitePushConsumer($endpoints, $consumerGroup, $parentTopic, [ + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'enableFifoConsumeAccelerator' => true, +]); + +// Subscribe to lite topics +try { + // subscribeLite() may fail due to network errors or quota issues + $consumer->subscribeLite('lite-topic-1', function($messageView) { + $body = $messageView->getBody() ?? ''; + echo "Consume lite-topic-1 message: " . $body . "\n"; + return ConsumeResult::SUCCESS; + }); + $consumer->subscribeLite('lite-topic-2', function($messageView) { + $body = $messageView->getBody() ?? ''; + echo "Consume lite-topic-2 message: " . $body . "\n"; + return ConsumeResult::SUCCESS; + }); + $consumer->subscribeLite('lite-topic-3', function($messageView) { + $body = $messageView->getBody() ?? ''; + echo "Consume lite-topic-3 message: " . $body . "\n"; + return ConsumeResult::SUCCESS; + }); +} catch (\Exception $e) { + echo "Failed to subscribe lite topic: " . $e->getMessage() . "\n"; + exit(1); +} + +$consumer->start(); + +echo "Lite push consumer started. Press Ctrl+C to exit.\n"; + +$running = true; +if (function_exists('pcntl_signal')) { + pcntl_signal(SIGTERM, function () use (&$running) { + echo "Received SIGTERM, shutting down...\n"; + $running = false; + }); + pcntl_signal(SIGINT, function () use (&$running) { + echo "Received SIGINT, shutting down...\n"; + $running = false; + }); +} + +while ($running) { + if (function_exists('pcntl_signal_dispatch')) { + pcntl_signal_dispatch(); + } + sleep(1); +} + +$consumer->shutdown(); +echo "Lite push consumer shut down gracefully.\n"; diff --git a/php/examples/ProducerDelayMessageExample.php b/php/examples/ProducerDelayMessageExample.php new file mode 100644 index 000000000..8da5b7533 --- /dev/null +++ b/php/examples/ProducerDelayMessageExample.php @@ -0,0 +1,74 @@ +getEndpoints(); +$topic = $config->getTopic('delay'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +// Display configuration +$config->display(); + +$producer = new Producer($endpoints, [ + 'topics' => [$topic], + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'maxAttempts' => 3, + 'requestTimeout' => 3000, +]); + +$producer->start(); + +$topicResource = new Resource(); +$topicResource->setName($topic); + +// Set delivery timestamp to 10 seconds from now +$deliveryTimestamp = time() + 10; +$ts = new Timestamp(); +$ts->setSeconds($deliveryTimestamp); +$ts->setNanos(0); + +$sysProps = new SystemProperties(); +$sysProps->setTag('yourMessageTagA'); +$sysProps->setKeys(['yourMessageKey-3ee439f945d7']); +$sysProps->setDeliveryTimestamp($ts); + +$message = new Message(); +$message->setTopic($topicResource); +$message->setBody('This is a delay message for Apache RocketMQ'); +$message->setSystemProperties($sysProps); + +try { + $result = $producer->send($message); + echo "Send message successfully, messageId=" . $result['messageId'] . ", deliveryTimestamp=" . $deliveryTimestamp . "\n"; +} catch (\Throwable $e) { + echo "Failed to send message: " . $e->getMessage() . "\n"; +} + +$producer->shutdown(); diff --git a/php/examples/ProducerFifoMessageExample.php b/php/examples/ProducerFifoMessageExample.php new file mode 100644 index 000000000..04f25a474 --- /dev/null +++ b/php/examples/ProducerFifoMessageExample.php @@ -0,0 +1,68 @@ +getEndpoints(); +$topic = $config->getTopic('fifo'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +// Display configuration +$config->display(); + +$producer = new Producer($endpoints, [ + 'topics' => [$topic], + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'maxAttempts' => 3, + 'requestTimeout' => 3000, +]); + +$producer->start(); + +$topicResource = new Resource(); +$topicResource->setName($topic); + +$sysProps = new SystemProperties(); +$sysProps->setTag('yourMessageTagA'); +$sysProps->setKeys(['yourMessageKey-1ff69ada8e0e']); +// Message group decides the message delivery order. +$sysProps->setMessageGroup('yourMessageGroup0'); + +$message = new Message(); +$message->setTopic($topicResource); +$message->setBody('This is a FIFO message for Apache RocketMQ'); +$message->setSystemProperties($sysProps); + +try { + $result = $producer->send($message); + echo "Send message successfully, messageId=" . $result['messageId'] . "\n"; +} catch (\Throwable $e) { + echo "Failed to send message: " . $e->getMessage() . "\n"; +} + +$producer->shutdown(); diff --git a/php/examples/ProducerNormalMessageExample.php b/php/examples/ProducerNormalMessageExample.php new file mode 100644 index 000000000..22129cddb --- /dev/null +++ b/php/examples/ProducerNormalMessageExample.php @@ -0,0 +1,66 @@ +getEndpoints(); +$topic = $config->getTopic('normal'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +// Display configuration +$config->display(); + +$producer = new Producer($endpoints, [ + 'topics' => [$topic], + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'maxAttempts' => 3, + 'requestTimeout' => 3000, +]); + +$producer->start(); + +$topicResource = new Resource(); +$topicResource->setName($topic); + +$sysProps = new SystemProperties(); +$sysProps->setTag('yourMessageTagA'); +$sysProps->setKeys(['yourMessageKey-1c151062f96e']); + +$message = new Message(); +$message->setTopic($topicResource); +$message->setBody('This is a normal message for Apache RocketMQ'); +$message->setSystemProperties($sysProps); + +try { + $result = $producer->send($message); + echo "Send message successfully, messageId=" . $result['messageId'] . "\n"; +} catch (\Throwable $e) { + echo "Failed to send message: " . $e->getMessage() . "\n"; +} + +$producer->shutdown(); diff --git a/php/examples/ProducerPriorityMessageExample.php b/php/examples/ProducerPriorityMessageExample.php new file mode 100644 index 000000000..74652da84 --- /dev/null +++ b/php/examples/ProducerPriorityMessageExample.php @@ -0,0 +1,64 @@ +getEndpoints(); +$topic = $config->getTopic('lite'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +$producer = new Producer($endpoints, [ + 'topics' => [$topic], + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'maxAttempts' => 3, + 'requestTimeout' => 3000, +]); + +$producer->start(); + +$topicResource = new Resource(); +$topicResource->setName($topic); + +$sysProps = new SystemProperties(); +$sysProps->setTag('yourMessageTagA'); +$sysProps->setKeys(['yourMessageKey']); +$sysProps->setPriority(1); + +$message = new Message(); +$message->setTopic($topicResource); +$message->setBody('This is a priority message for Apache RocketMQ'); +$message->setSystemProperties($sysProps); + +try { + $result = $producer->send($message); + echo "Send message successfully, messageId=" . $result['messageId'] . "\n"; +} catch (\Throwable $e) { + echo "Failed to send message: " . $e->getMessage() . "\n"; +} + +$producer->shutdown(); diff --git a/php/examples/ProducerTransactionMessageExample.php b/php/examples/ProducerTransactionMessageExample.php new file mode 100644 index 000000000..0bf5d6b2a --- /dev/null +++ b/php/examples/ProducerTransactionMessageExample.php @@ -0,0 +1,72 @@ +getEndpoints(); +$topic = $config->getTopic('transaction'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +$producer = new Producer($endpoints, [ + 'topics' => [$topic], + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'maxAttempts' => 3, + 'requestTimeout' => 3000, +]); + +$producer->start(); + +$topicResource = new Resource(); +$topicResource->setName($topic); + +$sysProps = new SystemProperties(); +$sysProps->setTag('yourMessageTagA'); +$sysProps->setKeys(['yourMessageKey-565ef26f5727']); + +$message = new Message(); +$message->setTopic($topicResource); +$message->setBody('This is a transaction message for Apache RocketMQ'); +$message->setSystemProperties($sysProps); + +$transaction = $producer->beginTransaction(); + +try { + $result = $producer->sendWithTransaction($message, $transaction); + echo "Send transaction message successfully, messageId=" . $result['messageId'] . "\n"; +} catch (\Throwable $e) { + echo "Failed to send message: " . $e->getMessage() . "\n"; + $producer->shutdown(); + exit(1); +} + +// Commit the transaction. +$transaction->commit(); +// Or rollback the transaction. +// $transaction->rollback(); + +$producer->shutdown(); diff --git a/php/examples/PushConsumerExample.php b/php/examples/PushConsumerExample.php new file mode 100644 index 000000000..1818816ce --- /dev/null +++ b/php/examples/PushConsumerExample.php @@ -0,0 +1,72 @@ +getEndpoints(); +$consumerGroup = $config->getConsumerGroup(); +$topic = $config->getTopic('normal'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +// Display configuration +$config->display(); + +$consumer = new PushConsumer($endpoints, $consumerGroup, [ + 'subscriptionExpressions' => [$topic => '*'], + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'messageListener' => function($messageView) { + $body = $messageView->getBody() ?? ''; + echo "Consume message: " . $body . "\n"; + return ConsumeResult::SUCCESS; + }, + 'scanIntervalSeconds' => 5, +]); + +$consumer->start(); + +echo "Push consumer started. Press Ctrl+C to exit.\n"; + +$running = true; +if (function_exists('pcntl_signal')) { + pcntl_signal(SIGTERM, function () use (&$running) { + echo "Received SIGTERM, shutting down...\n"; + $running = false; + }); + pcntl_signal(SIGINT, function () use (&$running) { + echo "Received SIGINT, shutting down...\n"; + $running = false; + }); +} + +while ($running) { + if (function_exists('pcntl_signal_dispatch')) { + pcntl_signal_dispatch(); + } + sleep(1); +} + +$consumer->shutdown(); +echo "Push consumer shut down gracefully.\n"; diff --git a/php/examples/SimpleConsumerExample.php b/php/examples/SimpleConsumerExample.php new file mode 100644 index 000000000..41ca28d0a --- /dev/null +++ b/php/examples/SimpleConsumerExample.php @@ -0,0 +1,94 @@ +getEndpoints(); +$consumerGroup = $config->getConsumerGroup(); +$topic = $config->getTopic('normal'); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +// Display configuration +$config->display(); + +$consumer = new SimpleConsumer($endpoints, $consumerGroup, [ + 'credentials' => $credentials, + 'sslEnabled' => $sslEnabled, + 'awaitDuration' => 30, +]); + +$consumer->start(); +$consumer->subscribe($topic); + +echo "Simple consumer started. Press Ctrl+C to exit.\n"; + +// Receive messages in a loop +$maxMessageNum = 16; +$invisibleDuration = 15; + +$running = true; +if (function_exists('pcntl_signal')) { + pcntl_signal(SIGTERM, function () use (&$running) { + echo "Received SIGTERM, shutting down...\n"; + $running = false; + }); + pcntl_signal(SIGINT, function () use (&$running) { + echo "Received SIGINT, shutting down...\n"; + $running = false; + }); +} + +while ($running) { + if (function_exists('pcntl_signal_dispatch')) { + pcntl_signal_dispatch(); + } + + try { + $messages = $consumer->receive($maxMessageNum, $invisibleDuration); + if (empty($messages)) { + sleep(1); + continue; + } + + echo "Received " . count($messages) . " message(s)\n"; + + foreach ($messages as $msg) { + $body = $msg->getBody() ?? ''; + echo " Received: " . $body . "\n"; + + try { + $consumer->ack($msg); + echo " Acknowledged successfully\n"; + } catch (\Throwable $e) { + echo " Failed to acknowledge: " . $e->getMessage() . "\n"; + } + } + } catch (\Throwable $e) { + echo "Failed to receive message: " . $e->getMessage() . "\n"; + sleep(1); + } +} + +$consumer->shutdown(); +echo "Simple consumer shut down gracefully.\n"; diff --git a/php/examples/TlsConfigurationExample.php b/php/examples/TlsConfigurationExample.php new file mode 100644 index 000000000..a8978f467 --- /dev/null +++ b/php/examples/TlsConfigurationExample.php @@ -0,0 +1,139 @@ +isInsecure(), true) . "\n"; +echo " shouldVerifyPeer: " . var_export($insecure->shouldVerifyPeer(), true) . "\n\n"; + +// --------------------------------------------------------------------------- +// 2. Default TLS — convenience for development/testing. +// Uses insecure connection with verification disabled. +// --------------------------------------------------------------------------- +echo "2. Default TLS (Development)\n"; +echo " Use case: Quick-start development without TLS certificates.\n"; +echo " Security: Insecure — peer verification disabled.\n\n"; + +$default = TlsCredentials::createDefault(); +echo " isInsecure: " . var_export($default->isInsecure(), true) . "\n"; +echo " shouldVerifyPeer: " . var_export($default->shouldVerifyPeer(), true) . "\n"; +echo " shouldVerifyPeerName: " . var_export($default->shouldVerifyPeerName(), true) . "\n\n"; + +// --------------------------------------------------------------------------- +// 3. CA Certificate — one-way TLS for production. +// Server presents a certificate verified against a trusted CA. +// --------------------------------------------------------------------------- +echo "3. CA Certificate (One-Way TLS)\n"; +echo " Use case: Production environments with server-side TLS.\n"; +echo " Security: Server identity verified via CA certificate.\n"; +echo " Setup: Place your CA cert at a known path, e.g.:\n"; +echo " export ROCKETMQ_PHP_TLS_CA_CERT=/etc/ssl/ca-cert.pem\n\n"; + +$caCertPath = getenv('ROCKETMQ_PHP_TLS_CA_CERT') ?: '/path/to/ca-cert.pem'; +echo " Configured CA cert path: {$caCertPath}\n"; + +if (file_exists($caCertPath)) { + $withCa = TlsCredentials::createWithCa($caCertPath); + echo " CA cert found and loaded.\n"; + echo " shouldVerifyPeer: " . var_export($withCa->shouldVerifyPeer(), true) . "\n"; + echo " getCaCertPath: " . $withCa->getCaCertPath() . "\n"; +} else { + echo " (CA cert file not found — this is a demonstration path)\n"; + echo " To use: TlsCredentials::createWithCa('/actual/path/to/ca-cert.pem')\n"; +} +echo "\n"; + +// --------------------------------------------------------------------------- +// 4. Mutual TLS (mTLS) — two-way TLS for production. +// Both client and server present certificates. +// --------------------------------------------------------------------------- +echo "4. Mutual TLS (mTLS)\n"; +echo " Use case: Production environments requiring client authentication.\n"; +echo " Security: Both client and server identities verified.\n"; +echo " Setup: Place your certs at known paths, e.g.:\n"; +echo " export ROCKETMQ_PHP_TLS_CLIENT_CERT=/etc/ssl/client-cert.pem\n"; +echo " export ROCKETMQ_PHP_TLS_CLIENT_KEY=/etc/ssl/client-key.pem\n"; +echo " export ROCKETMQ_PHP_TLS_CA_CERT=/etc/ssl/ca-cert.pem # optional\n\n"; + +$clientCertPath = getenv('ROCKETMQ_PHP_TLS_CLIENT_CERT') ?: '/path/to/client-cert.pem'; +$clientKeyPath = getenv('ROCKETMQ_PHP_TLS_CLIENT_KEY') ?: '/path/to/client-key.pem'; + +echo " Configured client cert path: {$clientCertPath}\n"; +echo " Configured client key path: {$clientKeyPath}\n"; + +if (file_exists($clientCertPath) && file_exists($clientKeyPath)) { + $caForMtls = file_exists($caCertPath) ? $caCertPath : null; + $mtls = TlsCredentials::createMtls($clientCertPath, $clientKeyPath, $caForMtls); + echo " Client cert/key found and loaded.\n"; + echo " shouldVerifyPeer: " . var_export($mtls->shouldVerifyPeer(), true) . "\n"; + echo " getClientCertPath: " . $mtls->getClientCertPath() . "\n"; + echo " getClientKeyPath: " . $mtls->getClientKeyPath() . "\n"; +} else { + echo " (Client cert/key not found — this is a demonstration path)\n"; + echo " To use: TlsCredentials::createMtls(\n"; + echo " '/actual/path/to/client-cert.pem',\n"; + echo " '/actual/path/to/client-key.pem',\n"; + echo " '/actual/path/to/ca-cert.pem' // optional\n"; + echo " )\n"; +} +echo "\n"; + +// --------------------------------------------------------------------------- +// 5. Using TlsCredentials with a consumer/client +// --------------------------------------------------------------------------- +echo "5. Integration with Consumer/Producer\n"; +echo " Pass TlsCredentials via the 'tlsCredentials' option:\n\n"; +echo " \$tls = TlsCredentials::createWithCa('/etc/ssl/ca-cert.pem');\n"; +echo " \$consumer = new SimpleConsumer(\n"; +echo " 'broker:8080',\n"; +echo " 'GID_consumer',\n"; +echo " ['tlsCredentials' => \$tls]\n"; +echo " );\n\n"; + +// Demonstrate toChannelCredentials() +echo "6. Converting to gRPC ChannelCredentials\n"; +echo " The toChannelCredentials() method converts TlsCredentials\n"; +echo " into a Grpc\\ChannelCredentials instance for the gRPC extension.\n\n"; + +try { + $channelCreds = $insecure->toChannelCredentials(); + echo " Insecure → ChannelCredentials: " . ($channelCreds !== null ? get_class($channelCreds) : 'Insecure (null)') . "\n"; +} catch (\Exception $e) { + echo " Insecure → Error: " . $e->getMessage() . "\n"; +} + +echo "\n"; +echo "========================================\n"; +echo "TLS Configuration Examples Complete\n"; +echo "========================================\n"; diff --git a/php/examples/TlsIntegrationExample.php b/php/examples/TlsIntegrationExample.php new file mode 100644 index 000000000..29302aac8 --- /dev/null +++ b/php/examples/TlsIntegrationExample.php @@ -0,0 +1,185 @@ +getEndpoints(); +$topic = $config->getTopic('normal'); +$consumerGroup = $config->getConsumerGroup(); +$credentials = $config->getCredentials(); +$sslEnabled = $config->isSslEnabled(); + +$config->display(); + +// --------------------------------------------------------------------------- +// 1. Producer with TLS — CA Certificate (One-Way TLS) +// --------------------------------------------------------------------------- +echo "1. Producer with One-Way TLS (CA Certificate)\n\n"; + +$caCertPath = getenv('ROCKETMQ_PHP_TLS_CA_CERT') ?: '/path/to/ca-cert.pem'; + +if (file_exists($caCertPath)) { + $tls = TlsCredentials::createWithCa($caCertPath); + + $producer = (new ProducerBuilder()) + ->setEndpoints($endpoints) + ->setTopics($topic) + ->setTlsCredentials($tls) + ->setMaxAttempts(3) + ->build(); + + echo " Producer started with CA certificate TLS.\n"; + echo " TLS verifyPeer: " . var_export($tls->shouldVerifyPeer(), true) . "\n\n"; + + $producer->shutdown(); +} else { + echo " Skipped — CA cert not found at: {$caCertPath}\n"; + echo " Usage: export ROCKETMQ_PHP_TLS_CA_CERT=/etc/ssl/ca-cert.pem\n\n"; +} + +// --------------------------------------------------------------------------- +// 2. Producer with mTLS (Mutual TLS) +// --------------------------------------------------------------------------- +echo "2. Producer with Mutual TLS (mTLS)\n\n"; + +$clientCertPath = getenv('ROCKETMQ_PHP_TLS_CLIENT_CERT') ?: '/path/to/client-cert.pem'; +$clientKeyPath = getenv('ROCKETMQ_PHP_TLS_CLIENT_KEY') ?: '/path/to/client-key.pem'; + +if (file_exists($clientCertPath) && file_exists($clientKeyPath)) { + $caForMtls = file_exists($caCertPath) ? $caCertPath : null; + $mtls = TlsCredentials::createMtls($clientCertPath, $clientKeyPath, $caForMtls); + + $producer = (new ProducerBuilder()) + ->setEndpoints($endpoints) + ->setTopics($topic) + ->setTlsCredentials($mtls) + ->build(); + + echo " Producer started with mutual TLS.\n"; + echo " Client cert: " . $mtls->getClientCertPath() . "\n\n"; + + $producer->shutdown(); +} else { + echo " Skipped — client cert/key not found.\n"; + echo " Usage:\n"; + echo " export ROCKETMQ_PHP_TLS_CLIENT_CERT=/etc/ssl/client-cert.pem\n"; + echo " export ROCKETMQ_PHP_TLS_CLIENT_KEY=/etc/ssl/client-key.pem\n\n"; +} + +// --------------------------------------------------------------------------- +// 3. SimpleConsumer with TLS via ClientConfigurationBuilder +// --------------------------------------------------------------------------- +echo "3. SimpleConsumer with TLS via ClientConfigurationBuilder\n\n"; + +$ccBuilder = (new ClientConfigurationBuilder()) + ->setEndpoints($endpoints); + +if ($credentials !== null) { + $ccBuilder->setCredentialProvider($credentials); +} + +// Enable TLS with CA certificate +if (file_exists($caCertPath)) { + $tls = TlsCredentials::createWithCa($caCertPath); + $ccBuilder->setTlsCredentials($tls)->enableSsl(true); + echo " TLS enabled with CA certificate.\n"; +} else { + $ccBuilder->enableSsl($sslEnabled); + echo " TLS skipped, using default sslEnabled={$sslEnabled}.\n"; +} + +$clientConfig = $ccBuilder->build(); + +try { + $consumer = (new SimpleConsumerBuilder()) + ->setClientConfiguration($clientConfig) + ->setConsumerGroup($consumerGroup) + ->setAwaitDuration(30) + ->build(); + + echo " SimpleConsumer started with TLS configuration.\n"; + $consumer->shutdown(); +} catch (\Throwable $e) { + echo " SimpleConsumer start failed: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// --------------------------------------------------------------------------- +// 4. PushConsumer with Insecure Connection (local development) +// --------------------------------------------------------------------------- +echo "4. PushConsumer with Insecure Connection (local dev)\n\n"; + +$insecure = TlsCredentials::createInsecure(); + +$ccBuilder2 = (new ClientConfigurationBuilder()) + ->setEndpoints($endpoints) + ->setTlsCredentials($insecure) + ->enableSsl(true); + +if ($credentials !== null) { + $ccBuilder2->setCredentialProvider($credentials); +} + +$clientConfig2 = $ccBuilder2->build(); + +try { + $consumer = (new PushConsumerBuilder()) + ->setClientConfiguration($clientConfig2) + ->setConsumerGroup($consumerGroup) + ->setSubscriptionExpressions([$topic => '*']) + ->setMessageListener(function ($message) { + echo " Received: " . $message->getBody() . "\n"; + }) + ->build(); + + echo " PushConsumer started with insecure TLS (plaintext).\n"; + $consumer->shutdown(); +} catch (\Throwable $e) { + echo " PushConsumer start failed: " . $e->getMessage() . "\n"; +} +echo "\n"; + +// --------------------------------------------------------------------------- +// 5. Direct instantiation with tlsCredentials option +// --------------------------------------------------------------------------- +echo "5. Direct Instantiation with tlsCredentials Option\n\n"; +echo " \$tls = TlsCredentials::createWithCa('/etc/ssl/ca-cert.pem');\n"; +echo " \$producer = new Producer(\$endpoints, [\n"; +echo " 'topics' => ['\${topic}'],\n"; +echo " 'tlsCredentials' => \$tls,\n"; +echo " 'sslEnabled' => true,\n"; +echo " 'credentials' => \$credentials,\n"; +echo " ]);\n"; +echo " \$producer->start();\n\n"; + +echo "========================================\n"; +echo "TLS Integration Examples Complete\n"; +echo "========================================\n"; diff --git a/php/grpc/Apache/Rocketmq/V2/AckMessageEntry.php b/php/grpc/Apache/Rocketmq/V2/AckMessageEntry.php index 2ed878d67..7c4393cff 100644 --- a/php/grpc/Apache/Rocketmq/V2/AckMessageEntry.php +++ b/php/grpc/Apache/Rocketmq/V2/AckMessageEntry.php @@ -21,6 +21,10 @@ class AckMessageEntry extends \Google\Protobuf\Internal\Message * Generated from protobuf field string receipt_handle = 2; */ protected $receipt_handle = ''; + /** + * Generated from protobuf field optional string lite_topic = 3; + */ + protected $lite_topic = null; /** * Constructor. @@ -30,6 +34,7 @@ class AckMessageEntry extends \Google\Protobuf\Internal\Message * * @type string $message_id * @type string $receipt_handle + * @type string $lite_topic * } */ public function __construct($data = NULL) { @@ -81,5 +86,37 @@ public function setReceiptHandle($var) return $this; } + /** + * Generated from protobuf field optional string lite_topic = 3; + * @return string + */ + public function getLiteTopic() + { + return isset($this->lite_topic) ? $this->lite_topic : ''; + } + + public function hasLiteTopic() + { + return isset($this->lite_topic); + } + + public function clearLiteTopic() + { + unset($this->lite_topic); + } + + /** + * Generated from protobuf field optional string lite_topic = 3; + * @param string $var + * @return $this + */ + public function setLiteTopic($var) + { + GPBUtil::checkString($var, True); + $this->lite_topic = $var; + + return $this; + } + } diff --git a/php/grpc/Apache/Rocketmq/V2/AdminClient.php b/php/grpc/Apache/Rocketmq/V2/AdminClient.php deleted file mode 100644 index 48866e2a6..000000000 --- a/php/grpc/Apache/Rocketmq/V2/AdminClient.php +++ /dev/null @@ -1,49 +0,0 @@ -_simpleRequest('/apache.rocketmq.v2.Admin/ChangeLogLevel', - $argument, - ['\Apache\Rocketmq\V2\ChangeLogLevelResponse', 'decode'], - $metadata, $options); - } - -} diff --git a/php/grpc/Apache/Rocketmq/V2/ChangeInvisibleDurationRequest.php b/php/grpc/Apache/Rocketmq/V2/ChangeInvisibleDurationRequest.php index 828df8537..fdd4487dd 100644 --- a/php/grpc/Apache/Rocketmq/V2/ChangeInvisibleDurationRequest.php +++ b/php/grpc/Apache/Rocketmq/V2/ChangeInvisibleDurationRequest.php @@ -39,6 +39,16 @@ class ChangeInvisibleDurationRequest extends \Google\Protobuf\Internal\Message * Generated from protobuf field string message_id = 5; */ protected $message_id = ''; + /** + * Generated from protobuf field optional string lite_topic = 6; + */ + protected $lite_topic = null; + /** + * If true, server will not increment the retry times for this message + * + * Generated from protobuf field optional bool suspend = 7; + */ + protected $suspend = null; /** * Constructor. @@ -54,6 +64,9 @@ class ChangeInvisibleDurationRequest extends \Google\Protobuf\Internal\Message * New invisible duration * @type string $message_id * For message tracing + * @type string $lite_topic + * @type bool $suspend + * If true, server will not increment the retry times for this message * } */ public function __construct($data = NULL) { @@ -213,5 +226,73 @@ public function setMessageId($var) return $this; } + /** + * Generated from protobuf field optional string lite_topic = 6; + * @return string + */ + public function getLiteTopic() + { + return isset($this->lite_topic) ? $this->lite_topic : ''; + } + + public function hasLiteTopic() + { + return isset($this->lite_topic); + } + + public function clearLiteTopic() + { + unset($this->lite_topic); + } + + /** + * Generated from protobuf field optional string lite_topic = 6; + * @param string $var + * @return $this + */ + public function setLiteTopic($var) + { + GPBUtil::checkString($var, True); + $this->lite_topic = $var; + + return $this; + } + + /** + * If true, server will not increment the retry times for this message + * + * Generated from protobuf field optional bool suspend = 7; + * @return bool + */ + public function getSuspend() + { + return isset($this->suspend) ? $this->suspend : false; + } + + public function hasSuspend() + { + return isset($this->suspend); + } + + public function clearSuspend() + { + unset($this->suspend); + } + + /** + * If true, server will not increment the retry times for this message + * + * Generated from protobuf field optional bool suspend = 7; + * @param bool $var + * @return $this + */ + public function setSuspend($var) + { + GPBUtil::checkBool($var); + $this->suspend = $var; + + return $this; + } + } diff --git a/php/grpc/Apache/Rocketmq/V2/ChangeLogLevelRequest_Level.php b/php/grpc/Apache/Rocketmq/V2/ChangeLogLevelRequest_Level.php deleted file mode 100644 index 002cff2b0..000000000 --- a/php/grpc/Apache/Rocketmq/V2/ChangeLogLevelRequest_Level.php +++ /dev/null @@ -1,16 +0,0 @@ -SIMPLE_CONSUMER = 3; */ const SIMPLE_CONSUMER = 3; + /** + * Generated from protobuf enum PULL_CONSUMER = 4; + */ + const PULL_CONSUMER = 4; + /** + * Generated from protobuf enum LITE_PUSH_CONSUMER = 5; + */ + const LITE_PUSH_CONSUMER = 5; + /** + * Generated from protobuf enum LITE_SIMPLE_CONSUMER = 6; + */ + const LITE_SIMPLE_CONSUMER = 6; private static $valueToName = [ self::CLIENT_TYPE_UNSPECIFIED => 'CLIENT_TYPE_UNSPECIFIED', self::PRODUCER => 'PRODUCER', self::PUSH_CONSUMER => 'PUSH_CONSUMER', self::SIMPLE_CONSUMER => 'SIMPLE_CONSUMER', + self::PULL_CONSUMER => 'PULL_CONSUMER', + self::LITE_PUSH_CONSUMER => 'LITE_PUSH_CONSUMER', + self::LITE_SIMPLE_CONSUMER => 'LITE_SIMPLE_CONSUMER', ]; public static function name($value) diff --git a/php/grpc/Apache/Rocketmq/V2/Code.php b/php/grpc/Apache/Rocketmq/V2/Code.php index 473193aa4..0553dc073 100644 --- a/php/grpc/Apache/Rocketmq/V2/Code.php +++ b/php/grpc/Apache/Rocketmq/V2/Code.php @@ -135,6 +135,24 @@ class Code * Generated from protobuf enum CLIENT_ID_REQUIRED = 40017; */ const CLIENT_ID_REQUIRED = 40017; + /** + * Polling time is illegal. + * + * Generated from protobuf enum ILLEGAL_POLLING_TIME = 40018; + */ + const ILLEGAL_POLLING_TIME = 40018; + /** + * Offset is illegal. + * + * Generated from protobuf enum ILLEGAL_OFFSET = 40019; + */ + const ILLEGAL_OFFSET = 40019; + /** + * Format of lite topic is illegal. + * + * Generated from protobuf enum ILLEGAL_LITE_TOPIC = 40020; + */ + const ILLEGAL_LITE_TOPIC = 40020; /** * Generic code indicates that the client request lacks valid authentication * credentials for the requested resource. @@ -178,6 +196,12 @@ class Code * Generated from protobuf enum CONSUMER_GROUP_NOT_FOUND = 40403; */ const CONSUMER_GROUP_NOT_FOUND = 40403; + /** + * Offset not found from server. + * + * Generated from protobuf enum OFFSET_NOT_FOUND = 40404; + */ + const OFFSET_NOT_FOUND = 40404; /** * Generic code representing client side timeout when connecting to, reading data from, or write data to server. * @@ -196,6 +220,12 @@ class Code * Generated from protobuf enum MESSAGE_BODY_TOO_LARGE = 41301; */ const MESSAGE_BODY_TOO_LARGE = 41301; + /** + * Message body is empty. + * + * Generated from protobuf enum MESSAGE_BODY_EMPTY = 41302; + */ + const MESSAGE_BODY_EMPTY = 41302; /** * Generic code for use cases where pre-conditions are not met. * For example, if a producer instance is used to publish messages without prior start() invocation, @@ -211,6 +241,16 @@ class Code * Generated from protobuf enum TOO_MANY_REQUESTS = 42900; */ const TOO_MANY_REQUESTS = 42900; + /** + * LiteTopic related quota exceeded + * + * Generated from protobuf enum LITE_TOPIC_QUOTA_EXCEEDED = 42901; + */ + const LITE_TOPIC_QUOTA_EXCEEDED = 42901; + /** + * Generated from protobuf enum LITE_SUBSCRIPTION_QUOTA_EXCEEDED = 42902; + */ + const LITE_SUBSCRIPTION_QUOTA_EXCEEDED = 42902; /** * Generic code for the case that the server is unwilling to process the request because its header fields are too large. * The request may be resubmitted after reducing the size of the request header fields. @@ -325,6 +365,9 @@ class Code self::UNRECOGNIZED_CLIENT_TYPE => 'UNRECOGNIZED_CLIENT_TYPE', self::MESSAGE_CORRUPTED => 'MESSAGE_CORRUPTED', self::CLIENT_ID_REQUIRED => 'CLIENT_ID_REQUIRED', + self::ILLEGAL_POLLING_TIME => 'ILLEGAL_POLLING_TIME', + self::ILLEGAL_OFFSET => 'ILLEGAL_OFFSET', + self::ILLEGAL_LITE_TOPIC => 'ILLEGAL_LITE_TOPIC', self::UNAUTHORIZED => 'UNAUTHORIZED', self::PAYMENT_REQUIRED => 'PAYMENT_REQUIRED', self::FORBIDDEN => 'FORBIDDEN', @@ -332,11 +375,15 @@ class Code self::MESSAGE_NOT_FOUND => 'MESSAGE_NOT_FOUND', self::TOPIC_NOT_FOUND => 'TOPIC_NOT_FOUND', self::CONSUMER_GROUP_NOT_FOUND => 'CONSUMER_GROUP_NOT_FOUND', + self::OFFSET_NOT_FOUND => 'OFFSET_NOT_FOUND', self::REQUEST_TIMEOUT => 'REQUEST_TIMEOUT', self::PAYLOAD_TOO_LARGE => 'PAYLOAD_TOO_LARGE', self::MESSAGE_BODY_TOO_LARGE => 'MESSAGE_BODY_TOO_LARGE', + self::MESSAGE_BODY_EMPTY => 'MESSAGE_BODY_EMPTY', self::PRECONDITION_FAILED => 'PRECONDITION_FAILED', self::TOO_MANY_REQUESTS => 'TOO_MANY_REQUESTS', + self::LITE_TOPIC_QUOTA_EXCEEDED => 'LITE_TOPIC_QUOTA_EXCEEDED', + self::LITE_SUBSCRIPTION_QUOTA_EXCEEDED => 'LITE_SUBSCRIPTION_QUOTA_EXCEEDED', self::REQUEST_HEADER_FIELDS_TOO_LARGE => 'REQUEST_HEADER_FIELDS_TOO_LARGE', self::MESSAGE_PROPERTIES_TOO_LARGE => 'MESSAGE_PROPERTIES_TOO_LARGE', self::INTERNAL_ERROR => 'INTERNAL_ERROR', diff --git a/php/grpc/Apache/Rocketmq/V2/DeadLetterQueue.php b/php/grpc/Apache/Rocketmq/V2/DeadLetterQueue.php new file mode 100644 index 000000000..984cfe333 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/DeadLetterQueue.php @@ -0,0 +1,99 @@ +apache.rocketmq.v2.DeadLetterQueue + */ +class DeadLetterQueue extends \Google\Protobuf\Internal\Message +{ + /** + * Original topic for this DLQ message. + * + * Generated from protobuf field string topic = 1; + */ + protected $topic = ''; + /** + * Original message id for this DLQ message. + * + * Generated from protobuf field string message_id = 2; + */ + protected $message_id = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $topic + * Original topic for this DLQ message. + * @type string $message_id + * Original message id for this DLQ message. + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Definition::initOnce(); + parent::__construct($data); + } + + /** + * Original topic for this DLQ message. + * + * Generated from protobuf field string topic = 1; + * @return string + */ + public function getTopic() + { + return $this->topic; + } + + /** + * Original topic for this DLQ message. + * + * Generated from protobuf field string topic = 1; + * @param string $var + * @return $this + */ + public function setTopic($var) + { + GPBUtil::checkString($var, True); + $this->topic = $var; + + return $this; + } + + /** + * Original message id for this DLQ message. + * + * Generated from protobuf field string message_id = 2; + * @return string + */ + public function getMessageId() + { + return $this->message_id; + } + + /** + * Original message id for this DLQ message. + * + * Generated from protobuf field string message_id = 2; + * @param string $var + * @return $this + */ + public function setMessageId($var) + { + GPBUtil::checkString($var, True); + $this->message_id = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/Digest.php b/php/grpc/Apache/Rocketmq/V2/Digest.php index 839908369..1f1c64698 100644 --- a/php/grpc/Apache/Rocketmq/V2/Digest.php +++ b/php/grpc/Apache/Rocketmq/V2/Digest.php @@ -18,9 +18,6 @@ * 1) Standard messages should be negatively acknowledged instantly, causing * immediate re-delivery; 2) FIFO messages require special RPC, to re-fetch * previously acquired messages batch; - * Message consumption model also affects how invalid digest are handled. When - * messages are consumed in broadcasting way, - * TODO: define semantics of invalid-digest-when-broadcasting. * * Generated from protobuf message apache.rocketmq.v2.Digest */ diff --git a/php/grpc/Apache/Rocketmq/V2/ForwardMessageToDeadLetterQueueRequest.php b/php/grpc/Apache/Rocketmq/V2/ForwardMessageToDeadLetterQueueRequest.php index 641ffbd65..e83753ff2 100644 --- a/php/grpc/Apache/Rocketmq/V2/ForwardMessageToDeadLetterQueueRequest.php +++ b/php/grpc/Apache/Rocketmq/V2/ForwardMessageToDeadLetterQueueRequest.php @@ -37,6 +37,10 @@ class ForwardMessageToDeadLetterQueueRequest extends \Google\Protobuf\Internal\M * Generated from protobuf field int32 max_delivery_attempts = 6; */ protected $max_delivery_attempts = 0; + /** + * Generated from protobuf field optional string lite_topic = 7; + */ + protected $lite_topic = null; /** * Constructor. @@ -50,6 +54,7 @@ class ForwardMessageToDeadLetterQueueRequest extends \Google\Protobuf\Internal\M * @type string $message_id * @type int $delivery_attempt * @type int $max_delivery_attempts + * @type string $lite_topic * } */ public function __construct($data = NULL) { @@ -209,5 +214,37 @@ public function setMaxDeliveryAttempts($var) return $this; } + /** + * Generated from protobuf field optional string lite_topic = 7; + * @return string + */ + public function getLiteTopic() + { + return isset($this->lite_topic) ? $this->lite_topic : ''; + } + + public function hasLiteTopic() + { + return isset($this->lite_topic); + } + + public function clearLiteTopic() + { + unset($this->lite_topic); + } + + /** + * Generated from protobuf field optional string lite_topic = 7; + * @param string $var + * @return $this + */ + public function setLiteTopic($var) + { + GPBUtil::checkString($var, True); + $this->lite_topic = $var; + + return $this; + } + } diff --git a/php/grpc/Apache/Rocketmq/V2/GetOffsetRequest.php b/php/grpc/Apache/Rocketmq/V2/GetOffsetRequest.php new file mode 100644 index 000000000..bb3c6e1eb --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/GetOffsetRequest.php @@ -0,0 +1,105 @@ +apache.rocketmq.v2.GetOffsetRequest + */ +class GetOffsetRequest extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + */ + protected $group = null; + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + */ + protected $message_queue = null; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Resource $group + * @type \Apache\Rocketmq\V2\MessageQueue $message_queue + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + * @return \Apache\Rocketmq\V2\Resource|null + */ + public function getGroup() + { + return $this->group; + } + + public function hasGroup() + { + return isset($this->group); + } + + public function clearGroup() + { + unset($this->group); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + * @param \Apache\Rocketmq\V2\Resource $var + * @return $this + */ + public function setGroup($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Resource::class); + $this->group = $var; + + return $this; + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + * @return \Apache\Rocketmq\V2\MessageQueue|null + */ + public function getMessageQueue() + { + return $this->message_queue; + } + + public function hasMessageQueue() + { + return isset($this->message_queue); + } + + public function clearMessageQueue() + { + unset($this->message_queue); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + * @param \Apache\Rocketmq\V2\MessageQueue $var + * @return $this + */ + public function setMessageQueue($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\MessageQueue::class); + $this->message_queue = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/GetOffsetResponse.php b/php/grpc/Apache/Rocketmq/V2/GetOffsetResponse.php new file mode 100644 index 000000000..7ccd63ecd --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/GetOffsetResponse.php @@ -0,0 +1,95 @@ +apache.rocketmq.v2.GetOffsetResponse + */ +class GetOffsetResponse extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + */ + protected $status = null; + /** + * Generated from protobuf field int64 offset = 2; + */ + protected $offset = 0; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Status $status + * @type int|string $offset + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @return \Apache\Rocketmq\V2\Status|null + */ + public function getStatus() + { + return $this->status; + } + + public function hasStatus() + { + return isset($this->status); + } + + public function clearStatus() + { + unset($this->status); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @param \Apache\Rocketmq\V2\Status $var + * @return $this + */ + public function setStatus($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Status::class); + $this->status = $var; + + return $this; + } + + /** + * Generated from protobuf field int64 offset = 2; + * @return int|string + */ + public function getOffset() + { + return $this->offset; + } + + /** + * Generated from protobuf field int64 offset = 2; + * @param int|string $var + * @return $this + */ + public function setOffset($var) + { + GPBUtil::checkInt64($var); + $this->offset = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/Language.php b/php/grpc/Apache/Rocketmq/V2/Language.php index 98fa28bd9..aefaa7649 100644 --- a/php/grpc/Apache/Rocketmq/V2/Language.php +++ b/php/grpc/Apache/Rocketmq/V2/Language.php @@ -35,6 +35,34 @@ class Language * Generated from protobuf enum RUST = 5; */ const RUST = 5; + /** + * Generated from protobuf enum PYTHON = 6; + */ + const PYTHON = 6; + /** + * Generated from protobuf enum PHP = 7; + */ + const PHP = 7; + /** + * Generated from protobuf enum NODE_JS = 8; + */ + const NODE_JS = 8; + /** + * Generated from protobuf enum RUBY = 9; + */ + const RUBY = 9; + /** + * Generated from protobuf enum OBJECTIVE_C = 10; + */ + const OBJECTIVE_C = 10; + /** + * Generated from protobuf enum DART = 11; + */ + const DART = 11; + /** + * Generated from protobuf enum KOTLIN = 12; + */ + const KOTLIN = 12; private static $valueToName = [ self::LANGUAGE_UNSPECIFIED => 'LANGUAGE_UNSPECIFIED', @@ -43,6 +71,13 @@ class Language self::DOT_NET => 'DOT_NET', self::GOLANG => 'GOLANG', self::RUST => 'RUST', + self::PYTHON => 'PYTHON', + self::PHP => 'PHP', + self::NODE_JS => 'NODE_JS', + self::RUBY => 'RUBY', + self::OBJECTIVE_C => 'OBJECTIVE_C', + self::DART => 'DART', + self::KOTLIN => 'KOTLIN', ]; public static function name($value) diff --git a/php/grpc/Apache/Rocketmq/V2/LiteSubscriptionAction.php b/php/grpc/Apache/Rocketmq/V2/LiteSubscriptionAction.php new file mode 100644 index 000000000..c52a7e549 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/LiteSubscriptionAction.php @@ -0,0 +1,58 @@ +apache.rocketmq.v2.LiteSubscriptionAction + */ +class LiteSubscriptionAction +{ + /** + * Generated from protobuf enum PARTIAL_ADD = 0; + */ + const PARTIAL_ADD = 0; + /** + * Generated from protobuf enum PARTIAL_REMOVE = 1; + */ + const PARTIAL_REMOVE = 1; + /** + * Generated from protobuf enum COMPLETE_ADD = 2; + */ + const COMPLETE_ADD = 2; + /** + * Generated from protobuf enum COMPLETE_REMOVE = 3; + */ + const COMPLETE_REMOVE = 3; + + private static $valueToName = [ + self::PARTIAL_ADD => 'PARTIAL_ADD', + self::PARTIAL_REMOVE => 'PARTIAL_REMOVE', + self::COMPLETE_ADD => 'COMPLETE_ADD', + self::COMPLETE_REMOVE => 'COMPLETE_REMOVE', + ]; + + public static function name($value) + { + if (!isset(self::$valueToName[$value])) { + throw new UnexpectedValueException(sprintf( + 'Enum %s has no name defined for value %s', __CLASS__, $value)); + } + return self::$valueToName[$value]; + } + + + public static function value($name) + { + $const = __CLASS__ . '::' . strtoupper($name); + if (!defined($const)) { + throw new UnexpectedValueException(sprintf( + 'Enum %s has no value defined for name %s', __CLASS__, $name)); + } + return constant($const); + } +} + diff --git a/php/grpc/Apache/Rocketmq/V2/MessageType.php b/php/grpc/Apache/Rocketmq/V2/MessageType.php index 134452792..c6c727651 100644 --- a/php/grpc/Apache/Rocketmq/V2/MessageType.php +++ b/php/grpc/Apache/Rocketmq/V2/MessageType.php @@ -38,6 +38,18 @@ class MessageType * Generated from protobuf enum TRANSACTION = 4; */ const TRANSACTION = 4; + /** + * lite topic + * + * Generated from protobuf enum LITE = 5; + */ + const LITE = 5; + /** + * Messages that lower prioritised ones may need to wait for higher priority messages to be processed first + * + * Generated from protobuf enum PRIORITY = 6; + */ + const PRIORITY = 6; private static $valueToName = [ self::MESSAGE_TYPE_UNSPECIFIED => 'MESSAGE_TYPE_UNSPECIFIED', @@ -45,6 +57,8 @@ class MessageType self::FIFO => 'FIFO', self::DELAY => 'DELAY', self::TRANSACTION => 'TRANSACTION', + self::LITE => 'LITE', + self::PRIORITY => 'PRIORITY', ]; public static function name($value) diff --git a/php/grpc/Apache/Rocketmq/V2/MessagingServiceClient.php b/php/grpc/Apache/Rocketmq/V2/MessagingServiceClient.php index af63796f9..0bc50ff6c 100644 --- a/php/grpc/Apache/Rocketmq/V2/MessagingServiceClient.php +++ b/php/grpc/Apache/Rocketmq/V2/MessagingServiceClient.php @@ -1,266 +1,188 @@ _simpleRequest('/apache.rocketmq.v2.MessagingService/QueryRoute', - $argument, - ['\Apache\Rocketmq\V2\QueryRouteResponse', 'decode'], - $metadata, $options); + public function QueryRoute(\Apache\Rocketmq\V2\QueryRouteRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/QueryRoute', + $argument, + ['\Apache\Rocketmq\V2\QueryRouteResponse', 'decode'], + $metadata, + $options + ); } /** * Producer or consumer sends HeartbeatRequest to servers periodically to - * keep-alive. Additionally, it also reports client-side configuration, - * including topic subscription, load-balancing group name, etc. - * - * Returns `OK` if success. - * - * If a client specifies a language that is not yet supported by servers, - * returns `INVALID_ARGUMENT` - * @param \Apache\Rocketmq\V2\HeartbeatRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\UnaryCall + * keep-alive. */ - public function Heartbeat(\Apache\Rocketmq\V2\HeartbeatRequest $argument, - $metadata = [], $options = []) { - return $this->_simpleRequest('/apache.rocketmq.v2.MessagingService/Heartbeat', - $argument, - ['\Apache\Rocketmq\V2\HeartbeatResponse', 'decode'], - $metadata, $options); + public function Heartbeat(\Apache\Rocketmq\V2\HeartbeatRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/Heartbeat', + $argument, + ['\Apache\Rocketmq\V2\HeartbeatResponse', 'decode'], + $metadata, + $options + ); } /** * Delivers messages to brokers. - * Clients may further: - * 1. Refine a message destination to message-queues which fulfills parts of - * FIFO semantic; - * 2. Flag a message as transactional, which keeps it invisible to consumers - * until it commits; - * 3. Time a message, making it invisible to consumers till specified - * time-point; - * 4. And more... - * - * Returns message-id or transaction-id with status `OK` on success. - * - * If the destination topic doesn't exist, returns `NOT_FOUND`. - * @param \Apache\Rocketmq\V2\SendMessageRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\UnaryCall - */ - public function SendMessage(\Apache\Rocketmq\V2\SendMessageRequest $argument, - $metadata = [], $options = []) { - return $this->_simpleRequest('/apache.rocketmq.v2.MessagingService/SendMessage', - $argument, - ['\Apache\Rocketmq\V2\SendMessageResponse', 'decode'], - $metadata, $options); - } - - /** - * Queries the assigned route info of a topic for current consumer, - * the returned assignment result is decided by server-side load balancer. - * - * If the corresponding topic doesn't exist, returns `NOT_FOUND`. - * If the specific endpoints is empty, returns `INVALID_ARGUMENT`. - * @param \Apache\Rocketmq\V2\QueryAssignmentRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\UnaryCall */ - public function QueryAssignment(\Apache\Rocketmq\V2\QueryAssignmentRequest $argument, - $metadata = [], $options = []) { - return $this->_simpleRequest('/apache.rocketmq.v2.MessagingService/QueryAssignment', - $argument, - ['\Apache\Rocketmq\V2\QueryAssignmentResponse', 'decode'], - $metadata, $options); + public function SendMessage(\Apache\Rocketmq\V2\SendMessageRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/SendMessage', + $argument, + ['\Apache\Rocketmq\V2\SendMessageResponse', 'decode'], + $metadata, + $options + ); } /** - * Receives messages from the server in batch manner, returns a set of - * messages if success. The received messages should be acked or redelivered - * after processed. - * - * If the pending concurrent receive requests exceed the quota of the given - * consumer group, returns `UNAVAILABLE`. If the upstream store server hangs, - * return `DEADLINE_EXCEEDED` in a timely manner. If the corresponding topic - * or consumer group doesn't exist, returns `NOT_FOUND`. If there is no new - * message in the specific topic, returns `OK` with an empty message set. - * Please note that client may suffer from false empty responses. - * - * If failed to receive message from remote, server must return only one - * `ReceiveMessageResponse` as the reply to the request, whose `Status` indicates - * the specific reason of failure, otherwise, the reply is considered successful. - * @param \Apache\Rocketmq\V2\ReceiveMessageRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\ServerStreamingCall + * Receives messages from the server in batch manner. */ - public function ReceiveMessage(\Apache\Rocketmq\V2\ReceiveMessageRequest $argument, - $metadata = [], $options = []) { - return $this->_serverStreamRequest('/apache.rocketmq.v2.MessagingService/ReceiveMessage', - $argument, - ['\Apache\Rocketmq\V2\ReceiveMessageResponse', 'decode'], - $metadata, $options); + public function ReceiveMessage(\Apache\Rocketmq\V2\ReceiveMessageRequest $argument, $metadata = [], $options = []) { + return $this->_serverStreamRequest( + '/apache.rocketmq.v2.MessagingService/ReceiveMessage', + $argument, + ['\Apache\Rocketmq\V2\ReceiveMessageResponse', 'decode'], + $metadata, + $options + ); } /** - * Acknowledges the message associated with the `receipt_handle` or `offset` - * in the `AckMessageRequest`, it means the message has been successfully - * processed. Returns `OK` if the message server remove the relevant message - * successfully. - * - * If the given receipt_handle is illegal or out of date, returns - * `INVALID_ARGUMENT`. - * @param \Apache\Rocketmq\V2\AckMessageRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\UnaryCall + * Acknowledges the message associated with the receipt_handle. */ - public function AckMessage(\Apache\Rocketmq\V2\AckMessageRequest $argument, - $metadata = [], $options = []) { - return $this->_simpleRequest('/apache.rocketmq.v2.MessagingService/AckMessage', - $argument, - ['\Apache\Rocketmq\V2\AckMessageResponse', 'decode'], - $metadata, $options); + public function AckMessage(\Apache\Rocketmq\V2\AckMessageRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/AckMessage', + $argument, + ['\Apache\Rocketmq\V2\AckMessageResponse', 'decode'], + $metadata, + $options + ); } /** - * Forwards one message to dead letter queue if the max delivery attempts is - * exceeded by this message at client-side, return `OK` if success. - * @param \Apache\Rocketmq\V2\ForwardMessageToDeadLetterQueueRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\UnaryCall + * Forwards one message to dead letter queue. */ - public function ForwardMessageToDeadLetterQueue(\Apache\Rocketmq\V2\ForwardMessageToDeadLetterQueueRequest $argument, - $metadata = [], $options = []) { - return $this->_simpleRequest('/apache.rocketmq.v2.MessagingService/ForwardMessageToDeadLetterQueue', - $argument, - ['\Apache\Rocketmq\V2\ForwardMessageToDeadLetterQueueResponse', 'decode'], - $metadata, $options); + public function ForwardMessageToDeadLetterQueue(\Apache\Rocketmq\V2\ForwardMessageToDeadLetterQueueRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/ForwardMessageToDeadLetterQueue', + $argument, + ['\Apache\Rocketmq\V2\ForwardMessageToDeadLetterQueueResponse', 'decode'], + $metadata, + $options + ); } /** * Commits or rollback one transactional message. - * @param \Apache\Rocketmq\V2\EndTransactionRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\UnaryCall */ - public function EndTransaction(\Apache\Rocketmq\V2\EndTransactionRequest $argument, - $metadata = [], $options = []) { - return $this->_simpleRequest('/apache.rocketmq.v2.MessagingService/EndTransaction', - $argument, - ['\Apache\Rocketmq\V2\EndTransactionResponse', 'decode'], - $metadata, $options); + public function EndTransaction(\Apache\Rocketmq\V2\EndTransactionRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/EndTransaction', + $argument, + ['\Apache\Rocketmq\V2\EndTransactionResponse', 'decode'], + $metadata, + $options + ); } /** - * Once a client starts, it would immediately establishes bi-lateral stream - * RPCs with brokers, reporting its settings as the initiative command. - * - * When servers have need of inspecting client status, they would issue - * telemetry commands to clients. After executing received instructions, - * clients shall report command execution results through client-side streams. - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\BidiStreamingCall + * Telemetry bidirectional stream. */ public function Telemetry($metadata = [], $options = []) { - return $this->_bidiRequest('/apache.rocketmq.v2.MessagingService/Telemetry', - ['\Apache\Rocketmq\V2\TelemetryCommand','decode'], - $metadata, $options); + return $this->_bidiRequest( + '/apache.rocketmq.v2.MessagingService/Telemetry', + ['\Apache\Rocketmq\V2\TelemetryCommand', 'decode'], + $metadata, + $options + ); } /** * Notify the server that the client is terminated. - * @param \Apache\Rocketmq\V2\NotifyClientTerminationRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\UnaryCall */ - public function NotifyClientTermination(\Apache\Rocketmq\V2\NotifyClientTerminationRequest $argument, - $metadata = [], $options = []) { - return $this->_simpleRequest('/apache.rocketmq.v2.MessagingService/NotifyClientTermination', - $argument, - ['\Apache\Rocketmq\V2\NotifyClientTerminationResponse', 'decode'], - $metadata, $options); + public function NotifyClientTermination(\Apache\Rocketmq\V2\NotifyClientTerminationRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/NotifyClientTermination', + $argument, + ['\Apache\Rocketmq\V2\NotifyClientTerminationResponse', 'decode'], + $metadata, + $options + ); } /** * Once a message is retrieved from consume queue on behalf of the group, it - * will be kept invisible to other clients of the same group for a period of - * time. The message is supposed to be processed within the invisible - * duration. If the client, which is in charge of the invisible message, is - * not capable of processing the message timely, it may use - * ChangeInvisibleDuration to lengthen invisible duration. - * @param \Apache\Rocketmq\V2\ChangeInvisibleDurationRequest $argument input argument - * @param array $metadata metadata - * @param array $options call options - * @return \Grpc\UnaryCall + * will be kept invisible to other clients for a period of time. */ - public function ChangeInvisibleDuration(\Apache\Rocketmq\V2\ChangeInvisibleDurationRequest $argument, - $metadata = [], $options = []) { - return $this->_simpleRequest('/apache.rocketmq.v2.MessagingService/ChangeInvisibleDuration', - $argument, - ['\Apache\Rocketmq\V2\ChangeInvisibleDurationResponse', 'decode'], - $metadata, $options); + public function ChangeInvisibleDuration(\Apache\Rocketmq\V2\ChangeInvisibleDurationRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/ChangeInvisibleDuration', + $argument, + ['\Apache\Rocketmq\V2\ChangeInvisibleDurationResponse', 'decode'], + $metadata, + $options + ); } + /** + * Queries the assigned route info of a topic for current consumer. + */ + public function QueryAssignment(\Apache\Rocketmq\V2\QueryAssignmentRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/QueryAssignment', + $argument, + ['\Apache\Rocketmq\V2\QueryAssignmentResponse', 'decode'], + $metadata, + $options + ); + } + + /** + * Recalls a delay/timed message. + */ + public function RecallMessage(\Apache\Rocketmq\V2\RecallMessageRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/RecallMessage', + $argument, + ['\Apache\Rocketmq\V2\RecallMessageResponse', 'decode'], + $metadata, + $options + ); + } + + /** + * Sync lite subscription info, lite push consumer only. + */ + public function SyncLiteSubscription(\Apache\Rocketmq\V2\SyncLiteSubscriptionRequest $argument, $metadata = [], $options = []) { + return $this->_simpleRequest( + '/apache.rocketmq.v2.MessagingService/SyncLiteSubscription', + $argument, + ['\Apache\Rocketmq\V2\SyncLiteSubscriptionResponse', 'decode'], + $metadata, + $options + ); + } } diff --git a/php/grpc/Apache/Rocketmq/V2/Metric.php b/php/grpc/Apache/Rocketmq/V2/Metric.php index bcf25e67a..29c3df3d0 100644 --- a/php/grpc/Apache/Rocketmq/V2/Metric.php +++ b/php/grpc/Apache/Rocketmq/V2/Metric.php @@ -1,6 +1,6 @@ apache.rocketmq.v2.NotifyUnsubscribeLiteCommand + */ +class NotifyUnsubscribeLiteCommand extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field string lite_topic = 1; + */ + protected $lite_topic = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $lite_topic + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field string lite_topic = 1; + * @return string + */ + public function getLiteTopic() + { + return $this->lite_topic; + } + + /** + * Generated from protobuf field string lite_topic = 1; + * @param string $var + * @return $this + */ + public function setLiteTopic($var) + { + GPBUtil::checkString($var, True); + $this->lite_topic = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/OffsetOption.php b/php/grpc/Apache/Rocketmq/V2/OffsetOption.php new file mode 100644 index 000000000..2e0ab13c7 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/OffsetOption.php @@ -0,0 +1,152 @@ +apache.rocketmq.v2.OffsetOption + */ +class OffsetOption extends \Google\Protobuf\Internal\Message +{ + protected $offset_type; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type int $policy + * @type int|string $offset + * @type int|string $tail_n + * @type int|string $timestamp + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Definition::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.OffsetOption.Policy policy = 1; + * @return int + */ + public function getPolicy() + { + return $this->readOneof(1); + } + + public function hasPolicy() + { + return $this->hasOneof(1); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.OffsetOption.Policy policy = 1; + * @param int $var + * @return $this + */ + public function setPolicy($var) + { + GPBUtil::checkEnum($var, \Apache\Rocketmq\V2\OffsetOption\Policy::class); + $this->writeOneof(1, $var); + + return $this; + } + + /** + * Generated from protobuf field int64 offset = 2; + * @return int|string + */ + public function getOffset() + { + return $this->readOneof(2); + } + + public function hasOffset() + { + return $this->hasOneof(2); + } + + /** + * Generated from protobuf field int64 offset = 2; + * @param int|string $var + * @return $this + */ + public function setOffset($var) + { + GPBUtil::checkInt64($var); + $this->writeOneof(2, $var); + + return $this; + } + + /** + * Generated from protobuf field int64 tail_n = 3; + * @return int|string + */ + public function getTailN() + { + return $this->readOneof(3); + } + + public function hasTailN() + { + return $this->hasOneof(3); + } + + /** + * Generated from protobuf field int64 tail_n = 3; + * @param int|string $var + * @return $this + */ + public function setTailN($var) + { + GPBUtil::checkInt64($var); + $this->writeOneof(3, $var); + + return $this; + } + + /** + * Generated from protobuf field int64 timestamp = 4; + * @return int|string + */ + public function getTimestamp() + { + return $this->readOneof(4); + } + + public function hasTimestamp() + { + return $this->hasOneof(4); + } + + /** + * Generated from protobuf field int64 timestamp = 4; + * @param int|string $var + * @return $this + */ + public function setTimestamp($var) + { + GPBUtil::checkInt64($var); + $this->writeOneof(4, $var); + + return $this; + } + + /** + * @return string + */ + public function getOffsetType() + { + return $this->whichOneof("offset_type"); + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/OffsetOption/Policy.php b/php/grpc/Apache/Rocketmq/V2/OffsetOption/Policy.php new file mode 100644 index 000000000..3a496401f --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/OffsetOption/Policy.php @@ -0,0 +1,56 @@ +apache.rocketmq.v2.OffsetOption.Policy + */ +class Policy +{ + /** + * Generated from protobuf enum LAST = 0; + */ + const LAST = 0; + /** + * Generated from protobuf enum MIN = 1; + */ + const MIN = 1; + /** + * Generated from protobuf enum MAX = 2; + */ + const MAX = 2; + + private static $valueToName = [ + self::LAST => 'LAST', + self::MIN => 'MIN', + self::MAX => 'MAX', + ]; + + public static function name($value) + { + if (!isset(self::$valueToName[$value])) { + throw new UnexpectedValueException(sprintf( + 'Enum %s has no name defined for value %s', __CLASS__, $value)); + } + return self::$valueToName[$value]; + } + + + public static function value($name) + { + $const = __CLASS__ . '::' . strtoupper($name); + if (!defined($const)) { + throw new UnexpectedValueException(sprintf( + 'Enum %s has no value defined for name %s', __CLASS__, $name)); + } + return constant($const); + } +} + +// Adding a class alias for backwards compatibility with the previous class name. +class_alias(Policy::class, \Apache\Rocketmq\V2\OffsetOption_Policy::class); + diff --git a/php/grpc/Apache/Rocketmq/V2/Publishing.php b/php/grpc/Apache/Rocketmq/V2/Publishing.php index f0a71f186..9ba5cee91 100644 --- a/php/grpc/Apache/Rocketmq/V2/Publishing.php +++ b/php/grpc/Apache/Rocketmq/V2/Publishing.php @@ -1,6 +1,6 @@ apache.rocketmq.v2.PullMessageRequest + */ +class PullMessageRequest extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + */ + protected $group = null; + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + */ + protected $message_queue = null; + /** + * Generated from protobuf field int64 offset = 3; + */ + protected $offset = 0; + /** + * Generated from protobuf field int32 batch_size = 4; + */ + protected $batch_size = 0; + /** + * Generated from protobuf field .apache.rocketmq.v2.FilterExpression filter_expression = 5; + */ + protected $filter_expression = null; + /** + * Generated from protobuf field .google.protobuf.Duration long_polling_timeout = 6; + */ + protected $long_polling_timeout = null; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Resource $group + * @type \Apache\Rocketmq\V2\MessageQueue $message_queue + * @type int|string $offset + * @type int $batch_size + * @type \Apache\Rocketmq\V2\FilterExpression $filter_expression + * @type \Google\Protobuf\Duration $long_polling_timeout + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + * @return \Apache\Rocketmq\V2\Resource|null + */ + public function getGroup() + { + return $this->group; + } + + public function hasGroup() + { + return isset($this->group); + } + + public function clearGroup() + { + unset($this->group); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + * @param \Apache\Rocketmq\V2\Resource $var + * @return $this + */ + public function setGroup($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Resource::class); + $this->group = $var; + + return $this; + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + * @return \Apache\Rocketmq\V2\MessageQueue|null + */ + public function getMessageQueue() + { + return $this->message_queue; + } + + public function hasMessageQueue() + { + return isset($this->message_queue); + } + + public function clearMessageQueue() + { + unset($this->message_queue); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + * @param \Apache\Rocketmq\V2\MessageQueue $var + * @return $this + */ + public function setMessageQueue($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\MessageQueue::class); + $this->message_queue = $var; + + return $this; + } + + /** + * Generated from protobuf field int64 offset = 3; + * @return int|string + */ + public function getOffset() + { + return $this->offset; + } + + /** + * Generated from protobuf field int64 offset = 3; + * @param int|string $var + * @return $this + */ + public function setOffset($var) + { + GPBUtil::checkInt64($var); + $this->offset = $var; + + return $this; + } + + /** + * Generated from protobuf field int32 batch_size = 4; + * @return int + */ + public function getBatchSize() + { + return $this->batch_size; + } + + /** + * Generated from protobuf field int32 batch_size = 4; + * @param int $var + * @return $this + */ + public function setBatchSize($var) + { + GPBUtil::checkInt32($var); + $this->batch_size = $var; + + return $this; + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.FilterExpression filter_expression = 5; + * @return \Apache\Rocketmq\V2\FilterExpression|null + */ + public function getFilterExpression() + { + return $this->filter_expression; + } + + public function hasFilterExpression() + { + return isset($this->filter_expression); + } + + public function clearFilterExpression() + { + unset($this->filter_expression); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.FilterExpression filter_expression = 5; + * @param \Apache\Rocketmq\V2\FilterExpression $var + * @return $this + */ + public function setFilterExpression($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\FilterExpression::class); + $this->filter_expression = $var; + + return $this; + } + + /** + * Generated from protobuf field .google.protobuf.Duration long_polling_timeout = 6; + * @return \Google\Protobuf\Duration|null + */ + public function getLongPollingTimeout() + { + return $this->long_polling_timeout; + } + + public function hasLongPollingTimeout() + { + return isset($this->long_polling_timeout); + } + + public function clearLongPollingTimeout() + { + unset($this->long_polling_timeout); + } + + /** + * Generated from protobuf field .google.protobuf.Duration long_polling_timeout = 6; + * @param \Google\Protobuf\Duration $var + * @return $this + */ + public function setLongPollingTimeout($var) + { + GPBUtil::checkMessage($var, \Google\Protobuf\Duration::class); + $this->long_polling_timeout = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/PullMessageResponse.php b/php/grpc/Apache/Rocketmq/V2/PullMessageResponse.php new file mode 100644 index 000000000..186459420 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/PullMessageResponse.php @@ -0,0 +1,124 @@ +apache.rocketmq.v2.PullMessageResponse + */ +class PullMessageResponse extends \Google\Protobuf\Internal\Message +{ + protected $content; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Status $status + * @type \Apache\Rocketmq\V2\Message $message + * @type int|string $next_offset + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @return \Apache\Rocketmq\V2\Status|null + */ + public function getStatus() + { + return $this->readOneof(1); + } + + public function hasStatus() + { + return $this->hasOneof(1); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @param \Apache\Rocketmq\V2\Status $var + * @return $this + */ + public function setStatus($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Status::class); + $this->writeOneof(1, $var); + + return $this; + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Message message = 2; + * @return \Apache\Rocketmq\V2\Message|null + */ + public function getMessage() + { + return $this->readOneof(2); + } + + public function hasMessage() + { + return $this->hasOneof(2); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Message message = 2; + * @param \Apache\Rocketmq\V2\Message $var + * @return $this + */ + public function setMessage($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Message::class); + $this->writeOneof(2, $var); + + return $this; + } + + /** + * Generated from protobuf field int64 next_offset = 3; + * @return int|string + */ + public function getNextOffset() + { + return $this->readOneof(3); + } + + public function hasNextOffset() + { + return $this->hasOneof(3); + } + + /** + * Generated from protobuf field int64 next_offset = 3; + * @param int|string $var + * @return $this + */ + public function setNextOffset($var) + { + GPBUtil::checkInt64($var); + $this->writeOneof(3, $var); + + return $this; + } + + /** + * @return string + */ + public function getContent() + { + return $this->whichOneof("content"); + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/QueryOffsetPolicy.php b/php/grpc/Apache/Rocketmq/V2/QueryOffsetPolicy.php new file mode 100644 index 000000000..f3ae85e8b --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/QueryOffsetPolicy.php @@ -0,0 +1,59 @@ +apache.rocketmq.v2.QueryOffsetPolicy + */ +class QueryOffsetPolicy +{ + /** + * Use this option if client wishes to playback all existing messages. + * + * Generated from protobuf enum BEGINNING = 0; + */ + const BEGINNING = 0; + /** + * Use this option if client wishes to skip all existing messages. + * + * Generated from protobuf enum END = 1; + */ + const END = 1; + /** + * Use this option if time-based seek is targeted. + * + * Generated from protobuf enum TIMESTAMP = 2; + */ + const TIMESTAMP = 2; + + private static $valueToName = [ + self::BEGINNING => 'BEGINNING', + self::END => 'END', + self::TIMESTAMP => 'TIMESTAMP', + ]; + + public static function name($value) + { + if (!isset(self::$valueToName[$value])) { + throw new UnexpectedValueException(sprintf( + 'Enum %s has no name defined for value %s', __CLASS__, $value)); + } + return self::$valueToName[$value]; + } + + + public static function value($name) + { + $const = __CLASS__ . '::' . strtoupper($name); + if (!defined($const)) { + throw new UnexpectedValueException(sprintf( + 'Enum %s has no value defined for name %s', __CLASS__, $name)); + } + return constant($const); + } +} + diff --git a/php/grpc/Apache/Rocketmq/V2/QueryOffsetRequest.php b/php/grpc/Apache/Rocketmq/V2/QueryOffsetRequest.php new file mode 100644 index 000000000..b56145e71 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/QueryOffsetRequest.php @@ -0,0 +1,132 @@ +apache.rocketmq.v2.QueryOffsetRequest + */ +class QueryOffsetRequest extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 1; + */ + protected $message_queue = null; + /** + * Generated from protobuf field .apache.rocketmq.v2.QueryOffsetPolicy query_offset_policy = 2; + */ + protected $query_offset_policy = 0; + /** + * Generated from protobuf field optional .google.protobuf.Timestamp timestamp = 3; + */ + protected $timestamp = null; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\MessageQueue $message_queue + * @type int $query_offset_policy + * @type \Google\Protobuf\Timestamp $timestamp + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 1; + * @return \Apache\Rocketmq\V2\MessageQueue|null + */ + public function getMessageQueue() + { + return $this->message_queue; + } + + public function hasMessageQueue() + { + return isset($this->message_queue); + } + + public function clearMessageQueue() + { + unset($this->message_queue); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 1; + * @param \Apache\Rocketmq\V2\MessageQueue $var + * @return $this + */ + public function setMessageQueue($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\MessageQueue::class); + $this->message_queue = $var; + + return $this; + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.QueryOffsetPolicy query_offset_policy = 2; + * @return int + */ + public function getQueryOffsetPolicy() + { + return $this->query_offset_policy; + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.QueryOffsetPolicy query_offset_policy = 2; + * @param int $var + * @return $this + */ + public function setQueryOffsetPolicy($var) + { + GPBUtil::checkEnum($var, \Apache\Rocketmq\V2\QueryOffsetPolicy::class); + $this->query_offset_policy = $var; + + return $this; + } + + /** + * Generated from protobuf field optional .google.protobuf.Timestamp timestamp = 3; + * @return \Google\Protobuf\Timestamp|null + */ + public function getTimestamp() + { + return $this->timestamp; + } + + public function hasTimestamp() + { + return isset($this->timestamp); + } + + public function clearTimestamp() + { + unset($this->timestamp); + } + + /** + * Generated from protobuf field optional .google.protobuf.Timestamp timestamp = 3; + * @param \Google\Protobuf\Timestamp $var + * @return $this + */ + public function setTimestamp($var) + { + GPBUtil::checkMessage($var, \Google\Protobuf\Timestamp::class); + $this->timestamp = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/QueryOffsetResponse.php b/php/grpc/Apache/Rocketmq/V2/QueryOffsetResponse.php new file mode 100644 index 000000000..d408756aa --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/QueryOffsetResponse.php @@ -0,0 +1,95 @@ +apache.rocketmq.v2.QueryOffsetResponse + */ +class QueryOffsetResponse extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + */ + protected $status = null; + /** + * Generated from protobuf field int64 offset = 2; + */ + protected $offset = 0; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Status $status + * @type int|string $offset + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @return \Apache\Rocketmq\V2\Status|null + */ + public function getStatus() + { + return $this->status; + } + + public function hasStatus() + { + return isset($this->status); + } + + public function clearStatus() + { + unset($this->status); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @param \Apache\Rocketmq\V2\Status $var + * @return $this + */ + public function setStatus($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Status::class); + $this->status = $var; + + return $this; + } + + /** + * Generated from protobuf field int64 offset = 2; + * @return int|string + */ + public function getOffset() + { + return $this->offset; + } + + /** + * Generated from protobuf field int64 offset = 2; + * @param int|string $var + * @return $this + */ + public function setOffset($var) + { + GPBUtil::checkInt64($var); + $this->offset = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/RecallMessageRequest.php b/php/grpc/Apache/Rocketmq/V2/RecallMessageRequest.php new file mode 100644 index 000000000..07e401733 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/RecallMessageRequest.php @@ -0,0 +1,102 @@ +apache.rocketmq.v2.RecallMessageRequest + */ +class RecallMessageRequest extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource topic = 1; + */ + protected $topic = null; + /** + * Refer to SendResultEntry. + * + * Generated from protobuf field string recall_handle = 2; + */ + protected $recall_handle = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Resource $topic + * @type string $recall_handle + * Refer to SendResultEntry. + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource topic = 1; + * @return \Apache\Rocketmq\V2\Resource|null + */ + public function getTopic() + { + return $this->topic; + } + + public function hasTopic() + { + return isset($this->topic); + } + + public function clearTopic() + { + unset($this->topic); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource topic = 1; + * @param \Apache\Rocketmq\V2\Resource $var + * @return $this + */ + public function setTopic($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Resource::class); + $this->topic = $var; + + return $this; + } + + /** + * Refer to SendResultEntry. + * + * Generated from protobuf field string recall_handle = 2; + * @return string + */ + public function getRecallHandle() + { + return $this->recall_handle; + } + + /** + * Refer to SendResultEntry. + * + * Generated from protobuf field string recall_handle = 2; + * @param string $var + * @return $this + */ + public function setRecallHandle($var) + { + GPBUtil::checkString($var, True); + $this->recall_handle = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/RecallMessageResponse.php b/php/grpc/Apache/Rocketmq/V2/RecallMessageResponse.php new file mode 100644 index 000000000..e071a5ed8 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/RecallMessageResponse.php @@ -0,0 +1,95 @@ +apache.rocketmq.v2.RecallMessageResponse + */ +class RecallMessageResponse extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + */ + protected $status = null; + /** + * Generated from protobuf field string message_id = 2; + */ + protected $message_id = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Status $status + * @type string $message_id + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @return \Apache\Rocketmq\V2\Status|null + */ + public function getStatus() + { + return $this->status; + } + + public function hasStatus() + { + return isset($this->status); + } + + public function clearStatus() + { + unset($this->status); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @param \Apache\Rocketmq\V2\Status $var + * @return $this + */ + public function setStatus($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Status::class); + $this->status = $var; + + return $this; + } + + /** + * Generated from protobuf field string message_id = 2; + * @return string + */ + public function getMessageId() + { + return $this->message_id; + } + + /** + * Generated from protobuf field string message_id = 2; + * @param string $var + * @return $this + */ + public function setMessageId($var) + { + GPBUtil::checkString($var, True); + $this->message_id = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/ReceiveMessageRequest.php b/php/grpc/Apache/Rocketmq/V2/ReceiveMessageRequest.php index 18ab40359..96ede1d6e 100644 --- a/php/grpc/Apache/Rocketmq/V2/ReceiveMessageRequest.php +++ b/php/grpc/Apache/Rocketmq/V2/ReceiveMessageRequest.php @@ -41,6 +41,14 @@ class ReceiveMessageRequest extends \Google\Protobuf\Internal\Message * Generated from protobuf field bool auto_renew = 6; */ protected $auto_renew = false; + /** + * Generated from protobuf field optional .google.protobuf.Duration long_polling_timeout = 7; + */ + protected $long_polling_timeout = null; + /** + * Generated from protobuf field optional string attempt_id = 8; + */ + protected $attempt_id = null; /** * Constructor. @@ -56,6 +64,8 @@ class ReceiveMessageRequest extends \Google\Protobuf\Internal\Message * Required if client type is simple consumer. * @type bool $auto_renew * For message auto renew and clean + * @type \Google\Protobuf\Duration $long_polling_timeout + * @type string $attempt_id * } */ public function __construct($data = NULL) { @@ -243,5 +253,69 @@ public function setAutoRenew($var) return $this; } + /** + * Generated from protobuf field optional .google.protobuf.Duration long_polling_timeout = 7; + * @return \Google\Protobuf\Duration|null + */ + public function getLongPollingTimeout() + { + return $this->long_polling_timeout; + } + + public function hasLongPollingTimeout() + { + return isset($this->long_polling_timeout); + } + + public function clearLongPollingTimeout() + { + unset($this->long_polling_timeout); + } + + /** + * Generated from protobuf field optional .google.protobuf.Duration long_polling_timeout = 7; + * @param \Google\Protobuf\Duration $var + * @return $this + */ + public function setLongPollingTimeout($var) + { + GPBUtil::checkMessage($var, \Google\Protobuf\Duration::class); + $this->long_polling_timeout = $var; + + return $this; + } + + /** + * Generated from protobuf field optional string attempt_id = 8; + * @return string + */ + public function getAttemptId() + { + return isset($this->attempt_id) ? $this->attempt_id : ''; + } + + public function hasAttemptId() + { + return isset($this->attempt_id); + } + + public function clearAttemptId() + { + unset($this->attempt_id); + } + + /** + * Generated from protobuf field optional string attempt_id = 8; + * @param string $var + * @return $this + */ + public function setAttemptId($var) + { + GPBUtil::checkString($var, True); + $this->attempt_id = $var; + + return $this; + } + } diff --git a/php/grpc/Apache/Rocketmq/V2/ReconnectEndpointsCommand.php b/php/grpc/Apache/Rocketmq/V2/ReconnectEndpointsCommand.php new file mode 100644 index 000000000..cdf3038b2 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/ReconnectEndpointsCommand.php @@ -0,0 +1,58 @@ +apache.rocketmq.v2.ReconnectEndpointsCommand + */ +class ReconnectEndpointsCommand extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field string nonce = 1; + */ + protected $nonce = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $nonce + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field string nonce = 1; + * @return string + */ + public function getNonce() + { + return $this->nonce; + } + + /** + * Generated from protobuf field string nonce = 1; + * @param string $var + * @return $this + */ + public function setNonce($var) + { + GPBUtil::checkString($var, True); + $this->nonce = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/SendResultEntry.php b/php/grpc/Apache/Rocketmq/V2/SendResultEntry.php index eeec9a699..e07662aed 100644 --- a/php/grpc/Apache/Rocketmq/V2/SendResultEntry.php +++ b/php/grpc/Apache/Rocketmq/V2/SendResultEntry.php @@ -29,6 +29,12 @@ class SendResultEntry extends \Google\Protobuf\Internal\Message * Generated from protobuf field int64 offset = 4; */ protected $offset = 0; + /** + * Unique handle to identify message to recall, support delay message for now. + * + * Generated from protobuf field string recall_handle = 5; + */ + protected $recall_handle = ''; /** * Constructor. @@ -40,6 +46,8 @@ class SendResultEntry extends \Google\Protobuf\Internal\Message * @type string $message_id * @type string $transaction_id * @type int|string $offset + * @type string $recall_handle + * Unique handle to identify message to recall, support delay message for now. * } */ public function __construct($data = NULL) { @@ -145,5 +153,31 @@ public function setOffset($var) return $this; } + /** + * Unique handle to identify message to recall, support delay message for now. + * + * Generated from protobuf field string recall_handle = 5; + * @return string + */ + public function getRecallHandle() + { + return $this->recall_handle; + } + + /** + * Unique handle to identify message to recall, support delay message for now. + * + * Generated from protobuf field string recall_handle = 5; + * @param string $var + * @return $this + */ + public function setRecallHandle($var) + { + GPBUtil::checkString($var, True); + $this->recall_handle = $var; + + return $this; + } + } diff --git a/php/grpc/Apache/Rocketmq/V2/Settings.php b/php/grpc/Apache/Rocketmq/V2/Settings.php index afd1b8f8d..f3e4a400b 100644 --- a/php/grpc/Apache/Rocketmq/V2/Settings.php +++ b/php/grpc/Apache/Rocketmq/V2/Settings.php @@ -1,6 +1,6 @@ optional .google.protobuf.Duration long_polling_timeout = 5; */ protected $long_polling_timeout = null; + /** + * Only lite push consumer + * client-side lite subscription quota limit + * + * Generated from protobuf field optional int32 lite_subscription_quota = 6; + */ + protected $lite_subscription_quota = null; + /** + * Only lite push consumer + * Maximum length limit for lite topic + * + * Generated from protobuf field optional int32 max_lite_topic_size = 7; + */ + protected $max_lite_topic_size = null; /** * Constructor. @@ -76,10 +90,16 @@ class Subscription extends \Google\Protobuf\Internal\Message * @type \Google\Protobuf\Duration $long_polling_timeout * Long-polling timeout for `ReceiveMessageRequest`, which is essential for * push consumer. + * @type int $lite_subscription_quota + * Only lite push consumer + * client-side lite subscription quota limit + * @type int $max_lite_topic_size + * Only lite push consumer + * Maximum length limit for lite topic * } */ public function __construct($data = NULL) { - \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + \GPBMetadata\Apache\Rocketmq\V2\Definition::initOnce(); parent::__construct($data); } @@ -269,5 +289,81 @@ public function setLongPollingTimeout($var) return $this; } + /** + * Only lite push consumer + * client-side lite subscription quota limit + * + * Generated from protobuf field optional int32 lite_subscription_quota = 6; + * @return int + */ + public function getLiteSubscriptionQuota() + { + return isset($this->lite_subscription_quota) ? $this->lite_subscription_quota : 0; + } + + public function hasLiteSubscriptionQuota() + { + return isset($this->lite_subscription_quota); + } + + public function clearLiteSubscriptionQuota() + { + unset($this->lite_subscription_quota); + } + + /** + * Only lite push consumer + * client-side lite subscription quota limit + * + * Generated from protobuf field optional int32 lite_subscription_quota = 6; + * @param int $var + * @return $this + */ + public function setLiteSubscriptionQuota($var) + { + GPBUtil::checkInt32($var); + $this->lite_subscription_quota = $var; + + return $this; + } + + /** + * Only lite push consumer + * Maximum length limit for lite topic + * + * Generated from protobuf field optional int32 max_lite_topic_size = 7; + * @return int + */ + public function getMaxLiteTopicSize() + { + return isset($this->max_lite_topic_size) ? $this->max_lite_topic_size : 0; + } + + public function hasMaxLiteTopicSize() + { + return isset($this->max_lite_topic_size); + } + + public function clearMaxLiteTopicSize() + { + unset($this->max_lite_topic_size); + } + + /** + * Only lite push consumer + * Maximum length limit for lite topic + * + * Generated from protobuf field optional int32 max_lite_topic_size = 7; + * @param int $var + * @return $this + */ + public function setMaxLiteTopicSize($var) + { + GPBUtil::checkInt32($var); + $this->max_lite_topic_size = $var; + + return $this; + } + } diff --git a/php/grpc/Apache/Rocketmq/V2/SyncLiteSubscriptionRequest.php b/php/grpc/Apache/Rocketmq/V2/SyncLiteSubscriptionRequest.php new file mode 100644 index 000000000..6f6a99c61 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/SyncLiteSubscriptionRequest.php @@ -0,0 +1,254 @@ +apache.rocketmq.v2.SyncLiteSubscriptionRequest + */ +class SyncLiteSubscriptionRequest extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.LiteSubscriptionAction action = 1; + */ + protected $action = 0; + /** + * bindTopic for lite push consumer + * + * Generated from protobuf field .apache.rocketmq.v2.Resource topic = 2; + */ + protected $topic = null; + /** + * consumer group + * + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 3; + */ + protected $group = null; + /** + * lite subscription set of lite topics + * + * Generated from protobuf field repeated string lite_topic_set = 4; + */ + private $lite_topic_set; + /** + * Generated from protobuf field optional int64 version = 5; + */ + protected $version = null; + /** + * Generated from protobuf field optional .apache.rocketmq.v2.OffsetOption offset_option = 6; + */ + protected $offset_option = null; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type int $action + * @type \Apache\Rocketmq\V2\Resource $topic + * bindTopic for lite push consumer + * @type \Apache\Rocketmq\V2\Resource $group + * consumer group + * @type array|\Google\Protobuf\Internal\RepeatedField $lite_topic_set + * lite subscription set of lite topics + * @type int|string $version + * @type \Apache\Rocketmq\V2\OffsetOption $offset_option + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.LiteSubscriptionAction action = 1; + * @return int + */ + public function getAction() + { + return $this->action; + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.LiteSubscriptionAction action = 1; + * @param int $var + * @return $this + */ + public function setAction($var) + { + GPBUtil::checkEnum($var, \Apache\Rocketmq\V2\LiteSubscriptionAction::class); + $this->action = $var; + + return $this; + } + + /** + * bindTopic for lite push consumer + * + * Generated from protobuf field .apache.rocketmq.v2.Resource topic = 2; + * @return \Apache\Rocketmq\V2\Resource|null + */ + public function getTopic() + { + return $this->topic; + } + + public function hasTopic() + { + return isset($this->topic); + } + + public function clearTopic() + { + unset($this->topic); + } + + /** + * bindTopic for lite push consumer + * + * Generated from protobuf field .apache.rocketmq.v2.Resource topic = 2; + * @param \Apache\Rocketmq\V2\Resource $var + * @return $this + */ + public function setTopic($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Resource::class); + $this->topic = $var; + + return $this; + } + + /** + * consumer group + * + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 3; + * @return \Apache\Rocketmq\V2\Resource|null + */ + public function getGroup() + { + return $this->group; + } + + public function hasGroup() + { + return isset($this->group); + } + + public function clearGroup() + { + unset($this->group); + } + + /** + * consumer group + * + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 3; + * @param \Apache\Rocketmq\V2\Resource $var + * @return $this + */ + public function setGroup($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Resource::class); + $this->group = $var; + + return $this; + } + + /** + * lite subscription set of lite topics + * + * Generated from protobuf field repeated string lite_topic_set = 4; + * @return \Google\Protobuf\Internal\RepeatedField + */ + public function getLiteTopicSet() + { + return $this->lite_topic_set; + } + + /** + * lite subscription set of lite topics + * + * Generated from protobuf field repeated string lite_topic_set = 4; + * @param array|\Google\Protobuf\Internal\RepeatedField $var + * @return $this + */ + public function setLiteTopicSet($var) + { + $arr = GPBUtil::checkRepeatedField($var, \Google\Protobuf\Internal\GPBType::STRING); + $this->lite_topic_set = $arr; + + return $this; + } + + /** + * Generated from protobuf field optional int64 version = 5; + * @return int|string + */ + public function getVersion() + { + return isset($this->version) ? $this->version : 0; + } + + public function hasVersion() + { + return isset($this->version); + } + + public function clearVersion() + { + unset($this->version); + } + + /** + * Generated from protobuf field optional int64 version = 5; + * @param int|string $var + * @return $this + */ + public function setVersion($var) + { + GPBUtil::checkInt64($var); + $this->version = $var; + + return $this; + } + + /** + * Generated from protobuf field optional .apache.rocketmq.v2.OffsetOption offset_option = 6; + * @return \Apache\Rocketmq\V2\OffsetOption|null + */ + public function getOffsetOption() + { + return $this->offset_option; + } + + public function hasOffsetOption() + { + return isset($this->offset_option); + } + + public function clearOffsetOption() + { + unset($this->offset_option); + } + + /** + * Generated from protobuf field optional .apache.rocketmq.v2.OffsetOption offset_option = 6; + * @param \Apache\Rocketmq\V2\OffsetOption $var + * @return $this + */ + public function setOffsetOption($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\OffsetOption::class); + $this->offset_option = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/SyncLiteSubscriptionResponse.php b/php/grpc/Apache/Rocketmq/V2/SyncLiteSubscriptionResponse.php new file mode 100644 index 000000000..9ff3baf52 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/SyncLiteSubscriptionResponse.php @@ -0,0 +1,68 @@ +apache.rocketmq.v2.SyncLiteSubscriptionResponse + */ +class SyncLiteSubscriptionResponse extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + */ + protected $status = null; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Status $status + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @return \Apache\Rocketmq\V2\Status|null + */ + public function getStatus() + { + return $this->status; + } + + public function hasStatus() + { + return isset($this->status); + } + + public function clearStatus() + { + unset($this->status); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @param \Apache\Rocketmq\V2\Status $var + * @return $this + */ + public function setStatus($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Status::class); + $this->status = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/SystemProperties.php b/php/grpc/Apache/Rocketmq/V2/SystemProperties.php index 0ceeab900..5f0b5b1d2 100644 --- a/php/grpc/Apache/Rocketmq/V2/SystemProperties.php +++ b/php/grpc/Apache/Rocketmq/V2/SystemProperties.php @@ -141,6 +141,24 @@ class SystemProperties extends \Google\Protobuf\Internal\Message * Generated from protobuf field optional .google.protobuf.Duration orphaned_transaction_recovery_duration = 19; */ protected $orphaned_transaction_recovery_duration = null; + /** + * Information to identify whether this message is from dead letter queue. + * + * Generated from protobuf field optional .apache.rocketmq.v2.DeadLetterQueue dead_letter_queue = 20; + */ + protected $dead_letter_queue = null; + /** + * lite topic + * + * Generated from protobuf field optional string lite_topic = 21; + */ + protected $lite_topic = null; + /** + * Priority of message, which is optional + * + * Generated from protobuf field optional int32 priority = 22; + */ + protected $priority = null; /** * Constructor. @@ -200,6 +218,12 @@ class SystemProperties extends \Google\Protobuf\Internal\Message * `transaction_orphan_threshold`, it would be regarded as an * orphan. Servers that manages orphan messages would pick up * a capable publisher to resolve + * @type \Apache\Rocketmq\V2\DeadLetterQueue $dead_letter_queue + * Information to identify whether this message is from dead letter queue. + * @type string $lite_topic + * lite topic + * @type int $priority + * Priority of message, which is optional * } */ public function __construct($data = NULL) { @@ -849,5 +873,113 @@ public function setOrphanedTransactionRecoveryDuration($var) return $this; } + /** + * Information to identify whether this message is from dead letter queue. + * + * Generated from protobuf field optional .apache.rocketmq.v2.DeadLetterQueue dead_letter_queue = 20; + * @return \Apache\Rocketmq\V2\DeadLetterQueue|null + */ + public function getDeadLetterQueue() + { + return $this->dead_letter_queue; + } + + public function hasDeadLetterQueue() + { + return isset($this->dead_letter_queue); + } + + public function clearDeadLetterQueue() + { + unset($this->dead_letter_queue); + } + + /** + * Information to identify whether this message is from dead letter queue. + * + * Generated from protobuf field optional .apache.rocketmq.v2.DeadLetterQueue dead_letter_queue = 20; + * @param \Apache\Rocketmq\V2\DeadLetterQueue $var + * @return $this + */ + public function setDeadLetterQueue($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\DeadLetterQueue::class); + $this->dead_letter_queue = $var; + + return $this; + } + + /** + * lite topic + * + * Generated from protobuf field optional string lite_topic = 21; + * @return string + */ + public function getLiteTopic() + { + return isset($this->lite_topic) ? $this->lite_topic : ''; + } + + public function hasLiteTopic() + { + return isset($this->lite_topic); + } + + public function clearLiteTopic() + { + unset($this->lite_topic); + } + + /** + * lite topic + * + * Generated from protobuf field optional string lite_topic = 21; + * @param string $var + * @return $this + */ + public function setLiteTopic($var) + { + GPBUtil::checkString($var, True); + $this->lite_topic = $var; + + return $this; + } + + /** + * Priority of message, which is optional + * + * Generated from protobuf field optional int32 priority = 22; + * @return int + */ + public function getPriority() + { + return isset($this->priority) ? $this->priority : 0; + } + + public function hasPriority() + { + return isset($this->priority); + } + + public function clearPriority() + { + unset($this->priority); + } + + /** + * Priority of message, which is optional + * + * Generated from protobuf field optional int32 priority = 22; + * @param int $var + * @return $this + */ + public function setPriority($var) + { + GPBUtil::checkInt32($var); + $this->priority = $var; + + return $this; + } + } diff --git a/php/grpc/Apache/Rocketmq/V2/TelemetryCommand.php b/php/grpc/Apache/Rocketmq/V2/TelemetryCommand.php index 2009ad2eb..ac4f00883 100644 --- a/php/grpc/Apache/Rocketmq/V2/TelemetryCommand.php +++ b/php/grpc/Apache/Rocketmq/V2/TelemetryCommand.php @@ -40,6 +40,10 @@ class TelemetryCommand extends \Google\Protobuf\Internal\Message * Request client to print thread stack trace. * @type \Apache\Rocketmq\V2\VerifyMessageCommand $verify_message_command * Request client to verify the consumption of the appointed message. + * @type \Apache\Rocketmq\V2\ReconnectEndpointsCommand $reconnect_endpoints_command + * Request client to reconnect server use the latest endpoints. + * @type \Apache\Rocketmq\V2\NotifyUnsubscribeLiteCommand $notify_unsubscribe_lite_command + * Request client to unsubscribe lite topic. * } */ public function __construct($data = NULL) { @@ -269,6 +273,68 @@ public function setVerifyMessageCommand($var) return $this; } + /** + * Request client to reconnect server use the latest endpoints. + * + * Generated from protobuf field .apache.rocketmq.v2.ReconnectEndpointsCommand reconnect_endpoints_command = 8; + * @return \Apache\Rocketmq\V2\ReconnectEndpointsCommand|null + */ + public function getReconnectEndpointsCommand() + { + return $this->readOneof(8); + } + + public function hasReconnectEndpointsCommand() + { + return $this->hasOneof(8); + } + + /** + * Request client to reconnect server use the latest endpoints. + * + * Generated from protobuf field .apache.rocketmq.v2.ReconnectEndpointsCommand reconnect_endpoints_command = 8; + * @param \Apache\Rocketmq\V2\ReconnectEndpointsCommand $var + * @return $this + */ + public function setReconnectEndpointsCommand($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\ReconnectEndpointsCommand::class); + $this->writeOneof(8, $var); + + return $this; + } + + /** + * Request client to unsubscribe lite topic. + * + * Generated from protobuf field .apache.rocketmq.v2.NotifyUnsubscribeLiteCommand notify_unsubscribe_lite_command = 9; + * @return \Apache\Rocketmq\V2\NotifyUnsubscribeLiteCommand|null + */ + public function getNotifyUnsubscribeLiteCommand() + { + return $this->readOneof(9); + } + + public function hasNotifyUnsubscribeLiteCommand() + { + return $this->hasOneof(9); + } + + /** + * Request client to unsubscribe lite topic. + * + * Generated from protobuf field .apache.rocketmq.v2.NotifyUnsubscribeLiteCommand notify_unsubscribe_lite_command = 9; + * @param \Apache\Rocketmq\V2\NotifyUnsubscribeLiteCommand $var + * @return $this + */ + public function setNotifyUnsubscribeLiteCommand($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\NotifyUnsubscribeLiteCommand::class); + $this->writeOneof(9, $var); + + return $this; + } + /** * @return string */ diff --git a/php/grpc/Apache/Rocketmq/V2/UpdateOffsetRequest.php b/php/grpc/Apache/Rocketmq/V2/UpdateOffsetRequest.php new file mode 100644 index 000000000..b3e517413 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/UpdateOffsetRequest.php @@ -0,0 +1,132 @@ +apache.rocketmq.v2.UpdateOffsetRequest + */ +class UpdateOffsetRequest extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + */ + protected $group = null; + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + */ + protected $message_queue = null; + /** + * Generated from protobuf field int64 offset = 3; + */ + protected $offset = 0; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Resource $group + * @type \Apache\Rocketmq\V2\MessageQueue $message_queue + * @type int|string $offset + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + * @return \Apache\Rocketmq\V2\Resource|null + */ + public function getGroup() + { + return $this->group; + } + + public function hasGroup() + { + return isset($this->group); + } + + public function clearGroup() + { + unset($this->group); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Resource group = 1; + * @param \Apache\Rocketmq\V2\Resource $var + * @return $this + */ + public function setGroup($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Resource::class); + $this->group = $var; + + return $this; + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + * @return \Apache\Rocketmq\V2\MessageQueue|null + */ + public function getMessageQueue() + { + return $this->message_queue; + } + + public function hasMessageQueue() + { + return isset($this->message_queue); + } + + public function clearMessageQueue() + { + unset($this->message_queue); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.MessageQueue message_queue = 2; + * @param \Apache\Rocketmq\V2\MessageQueue $var + * @return $this + */ + public function setMessageQueue($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\MessageQueue::class); + $this->message_queue = $var; + + return $this; + } + + /** + * Generated from protobuf field int64 offset = 3; + * @return int|string + */ + public function getOffset() + { + return $this->offset; + } + + /** + * Generated from protobuf field int64 offset = 3; + * @param int|string $var + * @return $this + */ + public function setOffset($var) + { + GPBUtil::checkInt64($var); + $this->offset = $var; + + return $this; + } + +} + diff --git a/php/grpc/Apache/Rocketmq/V2/UpdateOffsetResponse.php b/php/grpc/Apache/Rocketmq/V2/UpdateOffsetResponse.php new file mode 100644 index 000000000..fd10b2bf5 --- /dev/null +++ b/php/grpc/Apache/Rocketmq/V2/UpdateOffsetResponse.php @@ -0,0 +1,68 @@ +apache.rocketmq.v2.UpdateOffsetResponse + */ +class UpdateOffsetResponse extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + */ + protected $status = null; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type \Apache\Rocketmq\V2\Status $status + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Apache\Rocketmq\V2\Service::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @return \Apache\Rocketmq\V2\Status|null + */ + public function getStatus() + { + return $this->status; + } + + public function hasStatus() + { + return isset($this->status); + } + + public function clearStatus() + { + unset($this->status); + } + + /** + * Generated from protobuf field .apache.rocketmq.v2.Status status = 1; + * @param \Apache\Rocketmq\V2\Status $var + * @return $this + */ + public function setStatus($var) + { + GPBUtil::checkMessage($var, \Apache\Rocketmq\V2\Status::class); + $this->status = $var; + + return $this; + } + +} + diff --git a/php/grpc/GPBMetadata/Apache/Rocketmq/V2/Definition.php b/php/grpc/GPBMetadata/Apache/Rocketmq/V2/Definition.php index a987498ec..9088bd67a 100644 Binary files a/php/grpc/GPBMetadata/Apache/Rocketmq/V2/Definition.php and b/php/grpc/GPBMetadata/Apache/Rocketmq/V2/Definition.php differ diff --git a/php/grpc/GPBMetadata/Apache/Rocketmq/V2/Service.php b/php/grpc/GPBMetadata/Apache/Rocketmq/V2/Service.php index ddd7ea2d5..243b2cbcc 100644 Binary files a/php/grpc/GPBMetadata/Apache/Rocketmq/V2/Service.php and b/php/grpc/GPBMetadata/Apache/Rocketmq/V2/Service.php differ diff --git a/php/phpunit.xml b/php/phpunit.xml new file mode 100644 index 000000000..09569be88 --- /dev/null +++ b/php/phpunit.xml @@ -0,0 +1,57 @@ + + + + + + + ./tests + ./tests/integration + + + ./tests/integration + + + + + + . + + + ./grpc + ./vendor + ./examples + ./tests + ./autoload.php + + + + + + + + + + + + + diff --git a/php/phpunit.xsd b/php/phpunit.xsd new file mode 100644 index 000000000..0923fccc4 --- /dev/null +++ b/php/phpunit.xsd @@ -0,0 +1,344 @@ + + + + + + This Schema file defines the rules by which the XML configuration file of PHPUnit 9.6 may be structured. + + + + + + Root Element + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The main type specifying the document structure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/php/protocol/apache/rocketmq/v2/definition.proto b/php/protocol/apache/rocketmq/v2/definition.proto index 67e58b8ff..516474df4 100644 --- a/php/protocol/apache/rocketmq/v2/definition.proto +++ b/php/protocol/apache/rocketmq/v2/definition.proto @@ -146,6 +146,12 @@ enum MessageType { // Messages that are transactional. Only committed messages are delivered to // subscribers. TRANSACTION = 4; + + // lite topic + LITE = 5; + + // Messages that lower prioritised ones may need to wait for higher priority messages to be processed first + PRIORITY = 6; } enum DigestType { @@ -175,10 +181,6 @@ enum DigestType { // 1) Standard messages should be negatively acknowledged instantly, causing // immediate re-delivery; 2) FIFO messages require special RPC, to re-fetch // previously acquired messages batch; -// -// Message consumption model also affects how invalid digest are handled. When -// messages are consumed in broadcasting way, -// TODO: define semantics of invalid-digest-when-broadcasting. message Digest { DigestType type = 1; string checksum = 2; @@ -189,6 +191,9 @@ enum ClientType { PRODUCER = 1; PUSH_CONSUMER = 2; SIMPLE_CONSUMER = 3; + PULL_CONSUMER = 4; + LITE_PUSH_CONSUMER = 5; + LITE_SIMPLE_CONSUMER = 6; } enum Encoding { @@ -270,9 +275,26 @@ message SystemProperties { // orphan. Servers that manages orphan messages would pick up // a capable publisher to resolve optional google.protobuf.Duration orphaned_transaction_recovery_duration = 19; + + // Information to identify whether this message is from dead letter queue. + optional DeadLetterQueue dead_letter_queue = 20; + + // lite topic + optional string lite_topic = 21; + + // Priority of message, which is optional + optional int32 priority = 22; +} + +message DeadLetterQueue { + // Original topic for this DLQ message. + string topic = 1; + // Original message id for this DLQ message. + string message_id = 2; } message Message { + Resource topic = 1; // User defined key-value pairs. @@ -336,6 +358,12 @@ enum Code { MESSAGE_CORRUPTED = 40016; // Request is rejected due to missing of x-mq-client-id header. CLIENT_ID_REQUIRED = 40017; + // Polling time is illegal. + ILLEGAL_POLLING_TIME = 40018; + // Offset is illegal. + ILLEGAL_OFFSET = 40019; + // Format of lite topic is illegal. + ILLEGAL_LITE_TOPIC = 40020; // Generic code indicates that the client request lacks valid authentication // credentials for the requested resource. @@ -355,6 +383,8 @@ enum Code { TOPIC_NOT_FOUND = 40402; // Consumer group resource does not exist. CONSUMER_GROUP_NOT_FOUND = 40403; + // Offset not found from server. + OFFSET_NOT_FOUND = 40404; // Generic code representing client side timeout when connecting to, reading data from, or write data to server. REQUEST_TIMEOUT = 40800; @@ -363,6 +393,8 @@ enum Code { PAYLOAD_TOO_LARGE = 41300; // Message body size exceeds the threshold. MESSAGE_BODY_TOO_LARGE = 41301; + // Message body is empty. + MESSAGE_BODY_EMPTY = 41302; // Generic code for use cases where pre-conditions are not met. // For example, if a producer instance is used to publish messages without prior start() invocation, @@ -373,6 +405,10 @@ enum Code { // Requests are throttled. TOO_MANY_REQUESTS = 42900; + // LiteTopic related quota exceeded + LITE_TOPIC_QUOTA_EXCEEDED = 42901; + LITE_SUBSCRIPTION_QUOTA_EXCEEDED = 42902; + // Generic code for the case that the server is unwilling to process the request because its header fields are too large. // The request may be resubmitted after reducing the size of the request header fields. REQUEST_HEADER_FIELDS_TOO_LARGE = 43100; @@ -432,6 +468,13 @@ enum Language { DOT_NET = 3; GOLANG = 4; RUST = 5; + PYTHON = 6; + PHP = 7; + NODE_JS = 8; + RUBY = 9; + OBJECTIVE_C = 10; + DART = 11; + KOTLIN = 12; } // User Agent @@ -447,4 +490,131 @@ message UA { // Hostname of the node string hostname = 4; +} + +message Settings { + // Configurations for all clients. + optional ClientType client_type = 1; + + optional Endpoints access_point = 2; + + // If publishing of messages encounters throttling or server internal errors, + // publishers should implement automatic retries after progressive longer + // back-offs for consecutive errors. + // + // When processing message fails, `backoff_policy` describes an interval + // after which the message should be available to consume again. + // + // For FIFO messages, the interval should be relatively small because + // messages of the same message group would not be readily available until + // the prior one depletes its lifecycle. + optional RetryPolicy backoff_policy = 3; + + // Request timeout for RPCs excluding long-polling. + optional google.protobuf.Duration request_timeout = 4; + + oneof pub_sub { + Publishing publishing = 5; + + Subscription subscription = 6; + } + + // User agent details + UA user_agent = 7; + + Metric metric = 8; +} + +message Publishing { + // Publishing settings below here is appointed by client, thus it is + // unnecessary for server to push at present. + // + // List of topics to which messages will publish to. + repeated Resource topics = 1; + + // If the message body size exceeds `max_body_size`, broker servers would + // reject the request. As a result, it is advisable that Producer performs + // client-side check validation. + int32 max_body_size = 2; + + // When `validate_message_type` flag set `false`, no need to validate message's type + // with messageQueue's `accept_message_types` before publishing. + bool validate_message_type = 3; +} + +message Subscription { + // Subscription settings below here is appointed by client, thus it is + // unnecessary for server to push at present. + // + // Consumer group. + optional Resource group = 1; + + // Subscription for consumer. + repeated SubscriptionEntry subscriptions = 2; + + // Subscription settings below here are from server, it is essential for + // server to push. + // + // When FIFO flag is `true`, messages of the same message group are processed + // in first-in-first-out manner. + // + // Brokers will not deliver further messages of the same group until prior + // ones are completely acknowledged. + optional bool fifo = 3; + + // Message receive batch size here is essential for push consumer. + optional int32 receive_batch_size = 4; + + // Long-polling timeout for `ReceiveMessageRequest`, which is essential for + // push consumer. + optional google.protobuf.Duration long_polling_timeout = 5; + + // Only lite push consumer + // client-side lite subscription quota limit + optional int32 lite_subscription_quota = 6; + + // Only lite push consumer + // Maximum length limit for lite topic + optional int32 max_lite_topic_size = 7; +} + +enum LiteSubscriptionAction { + PARTIAL_ADD = 0; + PARTIAL_REMOVE = 1; + COMPLETE_ADD = 2; + COMPLETE_REMOVE = 3; +} + +message Metric { + // Indicates that if client should export local metrics to server. + bool on = 1; + + // The endpoint that client metrics should be exported to, which is required if the switch is on. + optional Endpoints endpoints = 2; +} + +enum QueryOffsetPolicy { + // Use this option if client wishes to playback all existing messages. + BEGINNING = 0; + + // Use this option if client wishes to skip all existing messages. + END = 1; + + // Use this option if time-based seek is targeted. + TIMESTAMP = 2; +} + +message OffsetOption { + oneof offset_type { + Policy policy = 1; + int64 offset = 2; + int64 tail_n = 3; + int64 timestamp = 4; + } + + enum Policy { + LAST = 0; + MIN = 1; + MAX = 2; + } } \ No newline at end of file diff --git a/php/protocol/apache/rocketmq/v2/service.proto b/php/protocol/apache/rocketmq/v2/service.proto index 715594e3d..b58ac41c9 100644 --- a/php/protocol/apache/rocketmq/v2/service.proto +++ b/php/protocol/apache/rocketmq/v2/service.proto @@ -66,6 +66,8 @@ message SendResultEntry { string message_id = 2; string transaction_id = 3; int64 offset = 4; + // Unique handle to identify message to recall, support delay message for now. + string recall_handle = 5; } message SendMessageResponse { @@ -96,6 +98,8 @@ message ReceiveMessageRequest { optional google.protobuf.Duration invisible_duration = 5; // For message auto renew and clean bool auto_renew = 6; + optional google.protobuf.Duration long_polling_timeout = 7; + optional string attempt_id = 8; } message ReceiveMessageResponse { @@ -110,6 +114,7 @@ message ReceiveMessageResponse { message AckMessageEntry { string message_id = 1; string receipt_handle = 2; + optional string lite_topic = 3; } message AckMessageRequest { @@ -129,6 +134,7 @@ message AckMessageResultEntry { } message AckMessageResponse { + // RPC tier status, which is used to represent RPC-level errors including // authentication, authorization, throttling and other general failures. Status status = 1; @@ -143,20 +149,17 @@ message ForwardMessageToDeadLetterQueueRequest { string message_id = 4; int32 delivery_attempt = 5; int32 max_delivery_attempts = 6; + optional string lite_topic = 7; } -message ForwardMessageToDeadLetterQueueResponse { - Status status = 1; -} +message ForwardMessageToDeadLetterQueueResponse { Status status = 1; } message HeartbeatRequest { optional Resource group = 1; ClientType client_type = 2; } -message HeartbeatResponse { - Status status = 1; -} +message HeartbeatResponse { Status status = 1; } message EndTransactionRequest { Resource topic = 1; @@ -167,13 +170,11 @@ message EndTransactionRequest { string trace_context = 6; } -message EndTransactionResponse { - Status status = 1; -} +message EndTransactionResponse { Status status = 1; } -message PrintThreadStackTraceCommand { - string nonce = 1; -} +message PrintThreadStackTraceCommand { string nonce = 1; } + +message ReconnectEndpointsCommand { string nonce = 1; } message ThreadStackTrace { string nonce = 1; @@ -194,90 +195,8 @@ message RecoverOrphanedTransactionCommand { string transaction_id = 2; } -message Publishing { - // Publishing settings below here is appointed by client, thus it is - // unnecessary for server to push at present. - // - // List of topics to which messages will publish to. - repeated Resource topics = 1; - - // If the message body size exceeds `max_body_size`, broker servers would - // reject the request. As a result, it is advisable that Producer performs - // client-side check validation. - int32 max_body_size = 2; - - // When `validate_message_type` flag set `false`, no need to validate message's type - // with messageQueue's `accept_message_types` before publishing. - bool validate_message_type = 3; -} - -message Subscription { - // Subscription settings below here is appointed by client, thus it is - // unnecessary for server to push at present. - // - // Consumer group. - optional Resource group = 1; - - // Subscription for consumer. - repeated SubscriptionEntry subscriptions = 2; - - // Subscription settings below here are from server, it is essential for - // server to push. - // - // When FIFO flag is `true`, messages of the same message group are processed - // in first-in-first-out manner. - // - // Brokers will not deliver further messages of the same group until prior - // ones are completely acknowledged. - optional bool fifo = 3; - - // Message receive batch size here is essential for push consumer. - optional int32 receive_batch_size = 4; - - // Long-polling timeout for `ReceiveMessageRequest`, which is essential for - // push consumer. - optional google.protobuf.Duration long_polling_timeout = 5; -} - -message Metric { - // Indicates that if client should export local metrics to server. - bool on = 1; - - // The endpoint that client metrics should be exported to, which is required if the switch is on. - optional Endpoints endpoints = 2; -} - -message Settings { - // Configurations for all clients. - optional ClientType client_type = 1; - - optional Endpoints access_point = 2; - - // If publishing of messages encounters throttling or server internal errors, - // publishers should implement automatic retries after progressive longer - // back-offs for consecutive errors. - // - // When processing message fails, `backoff_policy` describes an interval - // after which the message should be available to consume again. - // - // For FIFO messages, the interval should be relatively small because - // messages of the same message group would not be readily available until - // the prior one depletes its lifecycle. - optional RetryPolicy backoff_policy = 3; - - // Request timeout for RPCs excluding long-polling. - optional google.protobuf.Duration request_timeout = 4; - - oneof pub_sub { - Publishing publishing = 5; - - Subscription subscription = 6; - } - - // User agent details - UA user_agent = 7; - - Metric metric = 8; +message NotifyUnsubscribeLiteCommand { + string lite_topic = 1; } message TelemetryCommand { @@ -305,6 +224,12 @@ message TelemetryCommand { // Request client to verify the consumption of the appointed message. VerifyMessageCommand verify_message_command = 7; + + // Request client to reconnect server use the latest endpoints. + ReconnectEndpointsCommand reconnect_endpoints_command = 8; + + // Request client to unsubscribe lite topic. + NotifyUnsubscribeLiteCommand notify_unsubscribe_lite_command = 9; } } @@ -313,9 +238,7 @@ message NotifyClientTerminationRequest { optional Resource group = 1; } -message NotifyClientTerminationResponse { - Status status = 1; -} +message NotifyClientTerminationResponse { Status status = 1; } message ChangeInvisibleDurationRequest { Resource group = 1; @@ -329,6 +252,10 @@ message ChangeInvisibleDurationRequest { // For message tracing string message_id = 5; + + optional string lite_topic = 6; + // If true, server will not increment the retry times for this message + optional bool suspend = 7; } message ChangeInvisibleDurationResponse { @@ -338,6 +265,81 @@ message ChangeInvisibleDurationResponse { string receipt_handle = 2; } +message PullMessageRequest { + Resource group = 1; + MessageQueue message_queue = 2; + int64 offset = 3; + int32 batch_size = 4; + FilterExpression filter_expression = 5; + google.protobuf.Duration long_polling_timeout = 6; +} + +message PullMessageResponse { + oneof content { + Status status = 1; + Message message = 2; + int64 next_offset = 3; + } +} + +message UpdateOffsetRequest { + Resource group = 1; + MessageQueue message_queue = 2; + int64 offset = 3; +} + +message UpdateOffsetResponse { + Status status = 1; +} + +message GetOffsetRequest { + Resource group = 1; + MessageQueue message_queue = 2; +} + +message GetOffsetResponse { + Status status = 1; + int64 offset = 2; +} + +message QueryOffsetRequest { + MessageQueue message_queue = 1; + QueryOffsetPolicy query_offset_policy = 2; + optional google.protobuf.Timestamp timestamp = 3; +} + +message QueryOffsetResponse { + Status status = 1; + int64 offset = 2; +} + +message RecallMessageRequest { + Resource topic = 1; + // Refer to SendResultEntry. + string recall_handle = 2; +} + +message RecallMessageResponse { + Status status = 1; + string message_id = 2; +} + +message SyncLiteSubscriptionRequest { + LiteSubscriptionAction action = 1; + // bindTopic for lite push consumer + Resource topic = 2; + // consumer group + Resource group = 3; + // lite subscription set of lite topics + repeated string lite_topic_set = 4; + optional int64 version = 5; + optional OffsetOption offset_option = 6; +} + +message SyncLiteSubscriptionResponse { + Status status = 1; +} + // For all the RPCs in MessagingService, the following error handling policies // apply: // @@ -349,6 +351,7 @@ message ChangeInvisibleDurationResponse { // common.status.code == `RESOURCE_EXHAUSTED`. If any unexpected server-side // errors raise, return a response with common.status.code == `INTERNAL`. service MessagingService { + // Queries the route entries of the requested topic in the perspective of the // given endpoints. On success, servers should return a collection of // addressable message-queues. Note servers may return customized route @@ -356,8 +359,7 @@ service MessagingService { // // If the requested topic doesn't exist, returns `NOT_FOUND`. // If the specific endpoints is empty, returns `INVALID_ARGUMENT`. - rpc QueryRoute(QueryRouteRequest) returns (QueryRouteResponse) { - } + rpc QueryRoute(QueryRouteRequest) returns (QueryRouteResponse) {} // Producer or consumer sends HeartbeatRequest to servers periodically to // keep-alive. Additionally, it also reports client-side configuration, @@ -367,8 +369,7 @@ service MessagingService { // // If a client specifies a language that is not yet supported by servers, // returns `INVALID_ARGUMENT` - rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse) { - } + rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse) {} // Delivers messages to brokers. // Clients may further: @@ -383,8 +384,7 @@ service MessagingService { // Returns message-id or transaction-id with status `OK` on success. // // If the destination topic doesn't exist, returns `NOT_FOUND`. - rpc SendMessage(SendMessageRequest) returns (SendMessageResponse) { - } + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse) {} // Queries the assigned route info of a topic for current consumer, // the returned assignment result is decided by server-side load balancer. @@ -418,18 +418,30 @@ service MessagingService { // // If the given receipt_handle is illegal or out of date, returns // `INVALID_ARGUMENT`. - rpc AckMessage(AckMessageRequest) returns (AckMessageResponse) { - } + rpc AckMessage(AckMessageRequest) returns (AckMessageResponse) {} // Forwards one message to dead letter queue if the max delivery attempts is // exceeded by this message at client-side, return `OK` if success. rpc ForwardMessageToDeadLetterQueue(ForwardMessageToDeadLetterQueueRequest) - returns (ForwardMessageToDeadLetterQueueResponse) { - } + returns (ForwardMessageToDeadLetterQueueResponse) {} + + // PullMessage and ReceiveMessage RPCs serve a similar purpose, + // which is to attempt to get messages from the server, but with different semantics. + rpc PullMessage(PullMessageRequest) returns (stream PullMessageResponse) {} + + // Update the consumption progress of the designated queue of the + // consumer group to the remote. + rpc UpdateOffset(UpdateOffsetRequest) returns (UpdateOffsetResponse) {} + + // Query the consumption progress of the designated queue of the + // consumer group to the remote. + rpc GetOffset(GetOffsetRequest) returns (GetOffsetResponse) {} + + // Query the offset of the designated queue by the query offset policy. + rpc QueryOffset(QueryOffsetRequest) returns (QueryOffsetResponse) {} // Commits or rollback one transactional message. - rpc EndTransaction(EndTransactionRequest) returns (EndTransactionResponse) { - } + rpc EndTransaction(EndTransactionRequest) returns (EndTransactionResponse) {} // Once a client starts, it would immediately establishes bi-lateral stream // RPCs with brokers, reporting its settings as the initiative command. @@ -437,8 +449,7 @@ service MessagingService { // When servers have need of inspecting client status, they would issue // telemetry commands to clients. After executing received instructions, // clients shall report command execution results through client-side streams. - rpc Telemetry(stream TelemetryCommand) returns (stream TelemetryCommand) { - } + rpc Telemetry(stream TelemetryCommand) returns (stream TelemetryCommand) {} // Notify the server that the client is terminated. rpc NotifyClientTermination(NotifyClientTerminationRequest) returns (NotifyClientTerminationResponse) { @@ -452,4 +463,14 @@ service MessagingService { // ChangeInvisibleDuration to lengthen invisible duration. rpc ChangeInvisibleDuration(ChangeInvisibleDurationRequest) returns (ChangeInvisibleDurationResponse) { } + + // Recall a message, + // for delay message, should recall before delivery time, like the rollback operation of transaction message, + // for normal message, not supported for now. + rpc RecallMessage(RecallMessageRequest) returns (RecallMessageResponse) { + } + + // Sync lite subscription info, lite push consumer only + rpc SyncLiteSubscription(SyncLiteSubscriptionRequest) returns (SyncLiteSubscriptionResponse) {} + } \ No newline at end of file diff --git a/php/tests/ClientSessionTelemetryTest.php b/php/tests/ClientSessionTelemetryTest.php new file mode 100644 index 000000000..f96a29b44 --- /dev/null +++ b/php/tests/ClientSessionTelemetryTest.php @@ -0,0 +1,230 @@ +assertNotNull($session, "Session should not be null"); + $this->assertFalse($session->isSettingsSynced(), "New session should not be synced yet"); + } + + /** + * Tests that same endpoints returns same instance (singleton). + */ + public function testGetInstanceReturnsSameInstanceForSameEndpoints() + { + $fakeClient = new FakeMessagingClientForTelemetry(); + $session1 = TelemetrySession::getInstance($fakeClient, 'test-endpoints-2', 'client-2'); + $session2 = TelemetrySession::getInstance($fakeClient, 'test-endpoints-2', 'client-2'); + + $this->assertTrue( + $session1 === $session2, + "Same endpoints should return same session instance" + ); + } + + /** + * Tests that different endpoints returns different instances. + */ + public function testGetInstanceReturnsDifferentInstanceForDifferentEndpoints() + { + $fakeClient = new FakeMessagingClientForTelemetry(); + $session1 = TelemetrySession::getInstance($fakeClient, 'test-endpoints-3', 'client-3'); + $session2 = TelemetrySession::getInstance($fakeClient, 'test-endpoints-4', 'client-4'); + + $this->assertTrue( + $session1 !== $session2, + "Different endpoints should return different session instances" + ); + } + + /** + * Mirrors Java: testOnNextWithRecoverOrphanedTransactionCommand + * Tests that TelemetryCommand can be constructed with settings. + */ + public function testTelemetryCommandWithSettings() + { + $settings = new Settings(); + $settings->setClientType(ClientType::PUSH_CONSUMER); + + $command = new TelemetryCommand(); + $command->setSettings($settings); + + $this->assertTrue($command->hasSettings(), "Command should have settings"); + $this->assertEquals( + ClientType::PUSH_CONSUMER, + $command->getSettings()->getClientType(), + "Settings client type should match" + ); + } + + /** + * Mirrors Java: testOnNextWithUnrecognizedCommand + * Tests that empty TelemetryCommand has no sub-commands. + */ + public function testEmptyTelemetryCommand() + { + $command = new TelemetryCommand(); + + $this->assertFalse($command->hasSettings(), "Empty command should not have settings"); + } + + /** + * Tests that close() removes instance from pool. + */ + public function testCloseRemovesFromPool() + { + $fakeClient = new FakeMessagingClientForTelemetry(); + $session = TelemetrySession::getInstance($fakeClient, 'test-endpoints-5', 'client-5'); + $session->close(); + + // After close, getInstance should create a new instance + $newSession = TelemetrySession::getInstance($fakeClient, 'test-endpoints-5', 'client-5'); + + $this->assertTrue( + $session !== $newSession, + "After close, new getInstance should create a new session" + ); + } + + /** + * Tests writeSync returns false when stream is not initialized. + */ + public function testWriteSyncWithoutStream() + { + $fakeClient = new FakeMessagingClientForTelemetry(); + $session = TelemetrySession::getInstance($fakeClient, 'test-endpoints-6', 'client-6'); + + $settings = new Settings(); + $command = new TelemetryCommand(); + $command->setSettings($settings); + + // Without a real stream, writeSync should return false + $result = $session->writeSync($command); + + $this->assertFalse($result, "writeSync without stream should return false"); + } + + /** + * Tests that clientId is set correctly on session. + */ + public function testClientIdIsSet() + { + $fakeClient = new FakeMessagingClientForTelemetry(); + $session = TelemetrySession::getInstance($fakeClient, 'test-endpoints-7', 'my-custom-client-id'); + + $this->assertEquals( + 'my-custom-client-id', + $session->getClientId(), + "ClientId should match what was passed to getInstance" + ); + } + + /** + * Mirrors Java: testOnError + * Tests that session error state is tracked. + */ + public function testSessionInitialState() + { + $fakeClient = new FakeMessagingClientForTelemetry(); + $session = TelemetrySession::getInstance($fakeClient, 'test-endpoints-8', 'client-8'); + + $this->assertFalse($session->isSettingsSynced(), "Initial settingsSynced should be false"); + $this->assertNull($session->getSettingsError(), "Initial settingsError should be null"); + } + + /** + * Mirrors Java: testOnCompletedWithSessionHandlerIsNotRunning + * Tests that a fresh session can be constructed without errors. + */ + public function testSessionCanBeCreatedMultipleTimes() + { + $fakeClient = new FakeMessagingClientForTelemetry(); + + for ($i = 0; $i < 5; $i++) { + $session = TelemetrySession::getInstance($fakeClient, "test-endpoints-loop-{$i}", "client-loop-{$i}"); + $this->assertNotNull($session, "Session {$i} should be created"); + $session->close(); + } + } +} + +/** + * Fake gRPC MessagingServiceClient for telemetry tests. + * Implements minimal interface to satisfy type hints. + */ +class FakeMessagingClientForTelemetry { + private $stream; + + public function __construct($stream = null) + { + $this->stream = $stream ?: new FakeTelemetryStream(); + } + + public function Telemetry($metadata = []) + { + return $this->stream; + } +} + +/** + * Fake telemetry stream. + */ +class FakeTelemetryStream { + public function write($command) { return false; } + public function flush() {} + public function responses() { return []; } + public function cancel() {} + public function writesDone() {} +} + diff --git a/php/tests/ClientTraitTest.php b/php/tests/ClientTraitTest.php new file mode 100644 index 000000000..817105739 --- /dev/null +++ b/php/tests/ClientTraitTest.php @@ -0,0 +1,196 @@ +systemProperties = $sysProps; + if ($topicName !== null) { + $resource = new Resource(); + $resource->setName($topicName); + $this->topic = $resource; + } + } + + public function getSystemProperties(): ?object + { + return $this->systemProperties; + } + + public function getMessageId(): string + { + return $this->systemProperties?->getMessageId() ?? ''; + } + + public function getTopic(): string + { + return $this->topic?->getName() ?? ''; + } + + public function getDeliveryAttempt(): int { return 1; } + public function incrementDeliveryAttempt(): void {} + public function isCorrupted(): bool { return false; } + public function getEndpoints(): ?object { return null; } +} + +/** + * Concrete class that uses ClientTrait to test extract methods. + */ +class ClientTraitExtractor { + use \Apache\Rocketmq\ClientTrait; + + protected function getCredentials(): ?\Apache\Rocketmq\SessionCredentials + { + return null; + } + + protected function getClientIdValue(): string + { + return 'test-client-id'; + } + + protected function getNamespaceValue(): string + { + return ''; + } + + public function testExtractReceiptHandle($messageView): ?string + { + return $this->extractReceiptHandle($messageView); + } + + public function testExtractMessageId($messageView): ?string + { + return $this->extractMessageId($messageView); + } + + public function testExtractTopic($messageView): ?string + { + return $this->extractTopic($messageView); + } +} + +/** + * Tests for ClientTrait extraction methods. + * Mirrors Java's extractReceiptHandle, extractMessageId, extractTopic tests. + */ +class ClientTraitTest extends TestCase +{ + private $extractor; + + public function setUp(): void + { + $this->extractor = new ClientTraitExtractor(); + } + + public function testExtractReceiptHandleFromMessageView() + { + $sysProps = new SystemProperties(); + $sysProps->setReceiptHandle('test-receipt-handle-123'); + + $messageView = new FakeMessageViewForTrait($sysProps); + $handle = $this->extractor->testExtractReceiptHandle($messageView); + + $this->assertEquals( + 'test-receipt-handle-123', + $handle, + "Should extract receipt handle from message view" + ); + } + + public function testExtractReceiptHandleReturnsNullForNullSysProps() + { + // No sysProps - getSystemProperties returns null + $messageView = new FakeMessageViewForTrait(); + $handle = $this->extractor->testExtractReceiptHandle($messageView); + + $this->assertNull($handle, "Should return null when no system properties"); + } + + public function testExtractReceiptHandleReturnsEmptyForUnsetHandle() + { + // sysProps exists but no receipt handle set - protobuf returns '' + $sysProps = new SystemProperties(); + $messageView = new FakeMessageViewForTrait($sysProps); + $handle = $this->extractor->testExtractReceiptHandle($messageView); + + $this->assertEquals('', $handle, "Should return empty string when sysProps has no receipt handle"); + } + + public function testExtractMessageIdFromMessageView() + { + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-id-abc-123'); + + $messageView = new FakeMessageViewForTrait($sysProps); + $messageId = $this->extractor->testExtractMessageId($messageView); + + $this->assertEquals( + 'msg-id-abc-123', + $messageId, + "Should extract message ID from message view" + ); + } + + public function testExtractMessageIdReturnsEmptyForMissing() + { + // sysProps exists but no messageId set - protobuf returns '' + $sysProps = new SystemProperties(); + $messageView = new FakeMessageViewForTrait($sysProps); + $messageId = $this->extractor->testExtractMessageId($messageView); + + $this->assertEquals('', $messageId, "Should return empty string when sysProps has no message ID"); + } + + public function testExtractTopicFromMessageView() + { + $messageView = new FakeMessageViewForTrait(null, 'test-topic-name'); + $topic = $this->extractor->testExtractTopic($messageView); + + $this->assertEquals( + 'test-topic-name', + $topic, + "Should extract topic name from message view" + ); + } + + public function testExtractTopicReturnsNullForMissingTopic() + { + $messageView = new FakeMessageViewForTrait(); + $topic = $this->extractor->testExtractTopic($messageView); + + $this->assertNull($topic, "Should return null when no topic"); + } +} + diff --git a/php/tests/ConsumeResultTest.php b/php/tests/ConsumeResultTest.php new file mode 100644 index 000000000..d50fd3398 --- /dev/null +++ b/php/tests/ConsumeResultTest.php @@ -0,0 +1,49 @@ +assertEquals(0, ConsumeResult::SUCCESS->value, "SUCCESS should be 0"); + $this->assertEquals(1, ConsumeResult::FAILURE->value, "FAILURE should be 1"); + $this->assertTrue( + ConsumeResult::SUCCESS !== ConsumeResult::FAILURE, + "SUCCESS and FAILURE should be different" + ); + } + + public function testFromMixedInt() + { + $this->assertSame(ConsumeResult::SUCCESS, ConsumeResult::fromMixed(0)); + $this->assertSame(ConsumeResult::FAILURE, ConsumeResult::fromMixed(1)); + } + + public function testFromMixedEnum() + { + $this->assertSame(ConsumeResult::SUCCESS, ConsumeResult::fromMixed(ConsumeResult::SUCCESS)); + $this->assertSame(ConsumeResult::FAILURE, ConsumeResult::fromMixed(ConsumeResult::FAILURE)); + } +} diff --git a/php/tests/ConsumeServiceExtendedTest.php b/php/tests/ConsumeServiceExtendedTest.php new file mode 100644 index 000000000..1411dc353 --- /dev/null +++ b/php/tests/ConsumeServiceExtendedTest.php @@ -0,0 +1,192 @@ +buildMessageView('test-topic', 'hello'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::SUCCESS, + $result, + "Consume result should be SUCCESS" + ); + } + + /** + * Mirrors Java: testConsumeFailure - listener returns FAILURE. + */ + public function testConsumeMessageReturnsFailure() + { + $service = new TestConsumeService(function($msg) { + return ConsumeResult::FAILURE; + }); + + $msg = $this->buildMessageView('test-topic', 'hello'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::FAILURE, + $result, + "Consume result should be FAILURE" + ); + } + + /** + * Mirrors Java: testConsumeWithException - listener throws exception + * should be caught and return FAILURE. + */ + public function testConsumeMessageCatchesException() + { + $service = new TestConsumeService(function($msg) { + throw new \RuntimeException("Simulated listener error"); + }); + + $msg = $this->buildMessageView('test-topic', 'hello'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::FAILURE, + $result, + "Consume result should be FAILURE when listener throws" + ); + } + + /** + * Mirrors Java: testConsumeWithDelay - consume with delayed scheduling. + * In PHP this is simulated via the retry delay mechanism. + */ + public function testConsumeWithThrowable() + { + $service = new TestConsumeService(function($msg) { + throw new \InvalidArgumentException("Invalid argument"); + }); + + $msg = $this->buildMessageView('test-topic', 'hello'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::FAILURE, + $result, + "Consume result should be FAILURE for any Throwable" + ); + } + + /** + * Tests that consumeMessage returns SUCCESS for truthy return values. + */ + public function testConsumeMessageTruthyReturnsSuccess() + { + $service = new TestConsumeService(function($msg) { + return 0; // 0 is truthy in PHP but not FAILURE + }); + + $msg = $this->buildMessageView('test-topic', 'hello'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::SUCCESS, + $result, + "Non-FAILURE return should be treated as SUCCESS" + ); + } + + /** + * Helper to build a MessageView. + */ + private function buildMessageView($topic, $body) + { + $message = new Message(); + $message->setBody($body); + $topicResource = new Resource(); + $topicResource->setName($topic); + $message->setTopic($topicResource); + + $sysProps = new SystemProperties(); + $sysProps->setMessageId('test-msg-id'); + $message->setSystemProperties($sysProps); + + return new MessageView($message, 'test-receipt-handle', null, 1); + } +} + +/** + * Concrete test implementation of ConsumeService that exposes consumeMessage. + */ +class TestConsumeService extends ConsumeService +{ + public function __construct($listener) + { + $logger = Logger::getInstance('TestConsumeService'); + $fakeConsumer = new \FakeConsumer('test-client'); + parent::__construct($logger, $listener, $fakeConsumer); + } + + public function consume(ProcessQueue $pq): void + { + // Not used in these tests + } + + // Expose protected method for testing + public function consumeMessage(object $messageView): mixed + { + return parent::consumeMessage($messageView); + } +} + diff --git a/php/tests/ConsumeTaskTest.php b/php/tests/ConsumeTaskTest.php new file mode 100644 index 000000000..5c33f435a --- /dev/null +++ b/php/tests/ConsumeTaskTest.php @@ -0,0 +1,267 @@ +buildMessageView('test-topic', 'success-body'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::SUCCESS, + $result, + "Successful consume should return SUCCESS" + ); + $this->assertEquals( + 1, + $consumeCount, + "Listener should be called exactly once" + ); + } + + /** + * Mirrors Java: testCallWithConsumeException + * Tests that a listener throwing RuntimeException returns FAILURE. + */ + public function testCallWithConsumeException() + { + $service = new TestableStandardConsumeService(function($msg) { + throw new \RuntimeException("Simulated consume failure"); + }); + + $msg = $this->buildMessageView('test-topic', 'error-body'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::FAILURE, + $result, + "Listener exception should return FAILURE" + ); + } + + /** + * Tests FIFO consume success path. + */ + public function testFifoConsumeSuccess() + { + $consumeCount = 0; + $service = new TestableFifoConsumeService(function($msg) use (&$consumeCount) { + $consumeCount++; + return ConsumeResult::SUCCESS; + }); + + $msg = $this->buildMessageView('fifo-topic', 'fifo-body'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::SUCCESS, + $result, + "FIFO consume success should return SUCCESS" + ); + $this->assertEquals( + 1, + $consumeCount, + "FIFO listener should be called once" + ); + } + + /** + * Tests FIFO consume exception path. + */ + public function testFifoConsumeException() + { + $service = new TestableFifoConsumeService(function($msg) { + throw new \RuntimeException("FIFO consume failure"); + }); + + $msg = $this->buildMessageView('fifo-topic', 'fifo-error-body'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::FAILURE, + $result, + "FIFO listener exception should return FAILURE" + ); + } + + /** + * Tests that listener receives the correct message. + */ + public function testListenerReceivesCorrectMessage() + { + $receivedBody = null; + $receivedTopic = null; + $service = new TestableStandardConsumeService(function($msg) use (&$receivedBody, &$receivedTopic) { + $receivedBody = $msg->getBody(); + $receivedTopic = $msg->getTopic(); + return ConsumeResult::SUCCESS; + }); + + $msg = $this->buildMessageView('verify-topic', 'verify-body-content'); + $service->consumeMessage($msg); + + $this->assertEquals( + 'verify-body-content', + $receivedBody, + "Listener should receive correct message body" + ); + $this->assertEquals( + 'verify-topic', + $receivedTopic, + "Listener should receive correct topic" + ); + } + + /** + * Tests that Error (not Exception) is also caught as FAILURE. + */ + public function testListenerErrorReturnsFailure() + { + $service = new TestableStandardConsumeService(function($msg) { + throw new \Error("Fatal listener error"); + }); + + $msg = $this->buildMessageView('test-topic', 'error-body'); + $result = $service->consumeMessage($msg); + + $this->assertEquals( + ConsumeResult::FAILURE, + $result, + "Listener Error should also return FAILURE" + ); + } + + /** + * Helper to build a MessageView with a specific topic and body. + */ + private function buildMessageView($topic, $body) + { + $message = new Message(); + $message->setBody($body); + $topicResource = new Resource(); + $topicResource->setName($topic); + $message->setTopic($topicResource); + + $sysProps = new SystemProperties(); + $sysProps->setMessageId('test-msg-id'); + $message->setSystemProperties($sysProps); + + return new MessageView($message, 'test-receipt-handle', null, 1); + } +} + +/** + * Testable StandardConsumeService that exposes consumeMessage. + */ +class TestableStandardConsumeService extends StandardConsumeService +{ + public function __construct($listener) + { + $logger = Logger::getInstance('TestableStandardConsumeService'); + $fakeConsumer = $this->createFakeConsumer(); + parent::__construct($logger, $listener, $fakeConsumer); + } + + public function consume(ProcessQueue $pq): void + { + // Not used in these tests + } + + public function consumeMessage($messageView): mixed + { + return parent::consumeMessage($messageView); + } + + private function createFakeConsumer() + { + return new \FakeConsumer('test-client'); + } +} + +/** + * Testable FifoConsumeService that exposes consumeMessage. + */ +class TestableFifoConsumeService extends FifoConsumeService +{ + public function __construct($listener) + { + $logger = Logger::getInstance('TestableFifoConsumeService'); + $fakeConsumer = $this->createFakeConsumer(); + parent::__construct($logger, $listener, $fakeConsumer, false); + } + + public function consume(ProcessQueue $pq): void + { + // Not used in these tests + } + + public function consumeMessage(object $messageView): mixed + { + return parent::consumeMessage($messageView); + } + + private function createFakeConsumer() + { + return new \FakeConsumer('test-client'); + } +} + diff --git a/php/tests/CustomizedBackoffRetryPolicyTest.php b/php/tests/CustomizedBackoffRetryPolicyTest.php new file mode 100644 index 000000000..efdedf023 --- /dev/null +++ b/php/tests/CustomizedBackoffRetryPolicyTest.php @@ -0,0 +1,184 @@ +assertEquals(4, $policy->getMaxAttempts(), "Max attempts should be 4"); + $this->assertEquals(1000, $policy->getNextDelayMs(1), "Attempt 1 should be 1000ms"); + $this->assertEquals(5000, $policy->getNextDelayMs(2), "Attempt 2 should be 5000ms"); + $this->assertEquals(10000, $policy->getNextDelayMs(3), "Attempt 3 should be 10000ms"); + $this->assertEquals(0, $policy->getNextDelayMs(4), "Attempt 4 should be 0 (at max)"); + } + + public function testCyclesThroughDelays() + { + $policy = new CustomizedBackoffRetryPolicy(10, [100, 200]); + + $this->assertEquals(100, $policy->getNextDelayMs(1), "Attempt 1 should be 100ms"); + $this->assertEquals(200, $policy->getNextDelayMs(2), "Attempt 2 should be 200ms"); + $this->assertEquals(200, $policy->getNextDelayMs(3), "Attempt 3 should clamp to last delay (200ms)"); + $this->assertEquals(200, $policy->getNextDelayMs(4), "Attempt 4 should clamp to last delay (200ms)"); + } + + public function testEmptyDelays() + { + $policy = new CustomizedBackoffRetryPolicy(3, []); + + $this->assertEquals(0, $policy->getNextDelayMs(1), "Empty delays should return 0"); + } + + public function testRejectsNegativeMaxAttempts() + { + $this->expectException(\InvalidArgumentException::class); + new CustomizedBackoffRetryPolicy(0, []); + } + + /** + * Mirrors Java: testFromProtobuf + */ + public function testFromProtobuf() + { + $duration0 = new \Google\Protobuf\Duration(); + $duration0->setSeconds(1); + $duration1 = new \Google\Protobuf\Duration(); + $duration1->setSeconds(2); + $duration2 = new \Google\Protobuf\Duration(); + $duration2->setSeconds(3); + + $customizedBackoff = new \Apache\Rocketmq\V2\CustomizedBackoff(); + $customizedBackoff->setNext([$duration0, $duration1, $duration2]); + + $retryPolicyPb = new \Apache\Rocketmq\V2\RetryPolicy(); + $retryPolicyPb->setMaxAttempts(3); + $retryPolicyPb->setCustomizedBackoff($customizedBackoff); + + $policy = CustomizedBackoffRetryPolicy::fromProtobuf($retryPolicyPb); + + $this->assertEquals(3, $policy->getMaxAttempts(), "Max attempts should be 3"); + $durations = $policy->getDurations(); + $this->assertEquals(3, count($durations), "Should have 3 durations"); + $this->assertEquals(1000, $durations[0], "First duration should be 1000ms"); + $this->assertEquals(2000, $durations[1], "Second duration should be 2000ms"); + $this->assertEquals(3000, $durations[2], "Third duration should be 3000ms"); + } + + /** + * Mirrors Java: testFromProtobufWithoutCustomizedBackoff + */ + public function testFromProtobufWithoutCustomizedBackoff() + { + $exponentialBackoff = new \Apache\Rocketmq\V2\ExponentialBackoff(); + $retryPolicyPb = new \Apache\Rocketmq\V2\RetryPolicy(); + $retryPolicyPb->setMaxAttempts(3); + $retryPolicyPb->setExponentialBackoff($exponentialBackoff); + + $this->expectException(\InvalidArgumentException::class); + CustomizedBackoffRetryPolicy::fromProtobuf($retryPolicyPb); + } + + /** + * Mirrors Java: testToProtobuf + */ + public function testToProtobuf() + { + $policy = new CustomizedBackoffRetryPolicy(3, [1000, 2000]); + $protobuf = $policy->toProtobuf(); + + $this->assertEquals(3, $protobuf->getMaxAttempts(), "Max attempts should be 3"); + $this->assertTrue( + $protobuf->hasCustomizedBackoff(), + "Should have customized backoff" + ); + + $backoff = $protobuf->getCustomizedBackoff(); + $nextList = iterator_to_array($backoff->getNext()); + $this->assertEquals(2, count($nextList), "Should have 2 durations"); + + $d0Secs = $nextList[0]->getSeconds(); + $d1Secs = $nextList[1]->getSeconds(); + $this->assertEquals(1, $d0Secs, "First duration should be 1s"); + $this->assertEquals(2, $d1Secs, "Second duration should be 2s"); + } + + /** + * Mirrors Java: testInheritBackoff + */ + public function testInheritBackoff() + { + // Server-side durations: 1s, 2s, 3s + $duration0 = new \Google\Protobuf\Duration(); + $duration0->setSeconds(1); + $duration1 = new \Google\Protobuf\Duration(); + $duration1->setSeconds(2); + $duration2 = new \Google\Protobuf\Duration(); + $duration2->setSeconds(3); + + $serverBackoff = new \Apache\Rocketmq\V2\CustomizedBackoff(); + $serverBackoff->setNext([$duration0, $duration1, $duration2]); + + $serverPolicyPb = new \Apache\Rocketmq\V2\RetryPolicy(); + $serverPolicyPb->setMaxAttempts(5); + $serverPolicyPb->setCustomizedBackoff($serverBackoff); + + // Client-side: different delays + $clientPolicy = new CustomizedBackoffRetryPolicy(3, [3000, 2000, 1000]); + $inherited = $clientPolicy->inheritBackoff($serverPolicyPb); + + $this->assertEquals( + 3, + $inherited->getMaxAttempts(), + "Should keep own maxAttempts (3)" + ); + $inheritedDurations = $inherited->getDurations(); + $this->assertEquals(3, count($inheritedDurations), "Should have 3 inherited durations"); + $this->assertEquals(1000, $inheritedDurations[0], "First should be server's 1000ms"); + $this->assertEquals(2000, $inheritedDurations[1], "Second should be server's 2000ms"); + $this->assertEquals(3000, $inheritedDurations[2], "Third should be server's 3000ms"); + } + + /** + * Mirrors Java: testInheritBackoffWithoutCustomizedBackoff + */ + public function testInheritBackoffWithoutCustomizedBackoff() + { + $exponentialBackoff = new \Apache\Rocketmq\V2\ExponentialBackoff(); + $serverPolicyPb = new \Apache\Rocketmq\V2\RetryPolicy(); + $serverPolicyPb->setExponentialBackoff($exponentialBackoff); + + $clientPolicy = new CustomizedBackoffRetryPolicy(3, [3000, 2000, 1000]); + + $this->expectException(\InvalidArgumentException::class); + $clientPolicy->inheritBackoff($serverPolicyPb); + } +} diff --git a/php/tests/EncodingTest.php b/php/tests/EncodingTest.php new file mode 100644 index 000000000..70654143f --- /dev/null +++ b/php/tests/EncodingTest.php @@ -0,0 +1,72 @@ +assertEquals(0, Encoding::ENCODING_UNSPECIFIED, "ENCODING_UNSPECIFIED should be 0"); + $this->assertEquals(1, Encoding::IDENTITY, "IDENTITY should be 1"); + $this->assertEquals(2, Encoding::GZIP, "GZIP should be 2"); + } + + public function testEncodingNameLookup() + { + $this->assertEquals('ENCODING_UNSPECIFIED', Encoding::name(Encoding::ENCODING_UNSPECIFIED), "name(0) should return ENCODING_UNSPECIFIED"); + $this->assertEquals('IDENTITY', Encoding::name(Encoding::IDENTITY), "name(1) should return IDENTITY"); + $this->assertEquals('GZIP', Encoding::name(Encoding::GZIP), "name(2) should return GZIP"); + } + + public function testEncodingValueLookup() + { + $this->assertEquals(1, Encoding::value('IDENTITY'), "value('IDENTITY') should return 1"); + $this->assertEquals(2, Encoding::value('GZIP'), "value('GZIP') should return 2"); + + $this->expectException(\UnexpectedValueException::class); + Encoding::name(999); + } + + public function testDigestTypeValues() + { + $this->assertEquals(0, DigestType::DIGEST_TYPE_UNSPECIFIED, "DIGEST_TYPE_UNSPECIFIED should be 0"); + $this->assertEquals(1, DigestType::CRC32, "CRC32 should be 1"); + $this->assertEquals(2, DigestType::MD5, "MD5 should be 2"); + $this->assertEquals(3, DigestType::SHA1, "SHA1 should be 3"); + } + + public function testDigestTypeNameLookup() + { + $this->assertEquals('CRC32', DigestType::name(DigestType::CRC32), "name(CRC32) should return CRC32"); + $this->assertEquals('MD5', DigestType::name(DigestType::MD5), "name(MD5) should return MD5"); + $this->assertEquals('SHA1', DigestType::name(DigestType::SHA1), "name(SHA1) should return SHA1"); + } +} diff --git a/php/tests/EndpointsTest.php b/php/tests/EndpointsTest.php new file mode 100644 index 000000000..e2e1b1e7a --- /dev/null +++ b/php/tests/EndpointsTest.php @@ -0,0 +1,156 @@ +parseEndpoints($endpoints); + } +} + +/** + * Tests for parseEndpoints() in ClientTrait. + * Mirrors Java's EndpointsTest. + */ +class EndpointsTest extends TestCase +{ + private $client; + + public function setUp(): void + { + $this->client = new EndpointsTestClient(); + } + + public function testEndpointsWithSingleIpv4AndPort() + { + $endpoints = $this->client->testParseEndpoints('127.0.0.1:8080'); + + $this->assertEquals(AddressScheme::IPv4, $endpoints->getScheme(), "Scheme should be IPv4"); + $this->assertEquals(1, count($endpoints->getAddresses()), "Should have 1 address"); + + $address = $endpoints->getAddresses()[0]; + $this->assertEquals('127.0.0.1', $address->getHost(), "Host should be 127.0.0.1"); + $this->assertEquals(8080, $address->getPort(), "Port should be 8080"); + } + + public function testEndpointsWithSingleIpv4NoPort() + { + $endpoints = $this->client->testParseEndpoints('127.0.0.1'); + + $this->assertEquals(AddressScheme::IPv4, $endpoints->getScheme(), "Scheme should be IPv4"); + $this->assertEquals(1, count($endpoints->getAddresses()), "Should have 1 address"); + + $address = $endpoints->getAddresses()[0]; + $this->assertEquals('127.0.0.1', $address->getHost(), "Host should be 127.0.0.1"); + $this->assertEquals(80, $address->getPort(), "Port should default to 80"); + } + + public function testEndpointsWithDomainAndPort() + { + $endpoints = $this->client->testParseEndpoints('rocketmq.apache.org:8081'); + + $this->assertEquals(AddressScheme::DOMAIN_NAME, $endpoints->getScheme(), "Scheme should be DOMAIN_NAME"); + $this->assertEquals(1, count($endpoints->getAddresses()), "Should have 1 address"); + + $address = $endpoints->getAddresses()[0]; + $this->assertEquals('rocketmq.apache.org', $address->getHost(), "Host should be rocketmq.apache.org"); + $this->assertEquals(8081, $address->getPort(), "Port should be 8081"); + } + + public function testEndpointsWithDomainNoPort() + { + $endpoints = $this->client->testParseEndpoints('rocketmq.apache.org'); + + $this->assertEquals(AddressScheme::DOMAIN_NAME, $endpoints->getScheme(), "Scheme should be DOMAIN_NAME"); + $this->assertEquals(1, count($endpoints->getAddresses()), "Should have 1 address"); + + $address = $endpoints->getAddresses()[0]; + $this->assertEquals('rocketmq.apache.org', $address->getHost(), "Host should be rocketmq.apache.org"); + $this->assertEquals(80, $address->getPort(), "Port should default to 80"); + } + + public function testEndpointsWithDomainAndHttpPrefix() + { + $endpoints = $this->client->testParseEndpoints('http://rocketmq.apache.org'); + + $this->assertEquals(AddressScheme::DOMAIN_NAME, $endpoints->getScheme(), "Scheme should be DOMAIN_NAME"); + $address = $endpoints->getAddresses()[0]; + $this->assertEquals('rocketmq.apache.org', $address->getHost(), "HTTP prefix should be stripped"); + $this->assertEquals(80, $address->getPort(), "Port should default to 80"); + } + + public function testEndpointsWithDomainAndHttpsPrefix() + { + $endpoints = $this->client->testParseEndpoints('https://rocketmq.apache.org'); + + $this->assertEquals(AddressScheme::DOMAIN_NAME, $endpoints->getScheme(), "Scheme should be DOMAIN_NAME"); + $address = $endpoints->getAddresses()[0]; + $this->assertEquals('rocketmq.apache.org', $address->getHost(), "HTTPS prefix should be stripped"); + $this->assertEquals(80, $address->getPort(), "Port should default to 80"); + } + + public function testEndpointsWithDomainPortAndHttpPrefix() + { + $endpoints = $this->client->testParseEndpoints('http://rocketmq.apache.org:8081'); + + $this->assertEquals(AddressScheme::DOMAIN_NAME, $endpoints->getScheme(), "Scheme should be DOMAIN_NAME"); + $address = $endpoints->getAddresses()[0]; + $this->assertEquals('rocketmq.apache.org', $address->getHost(), "HTTP prefix should be stripped"); + $this->assertEquals(8081, $address->getPort(), "Port should be 8081"); + } + + public function testEndpointsWithDomainPortAndHttpsPrefix() + { + $endpoints = $this->client->testParseEndpoints('https://rocketmq.apache.org:8081'); + + $this->assertEquals(AddressScheme::DOMAIN_NAME, $endpoints->getScheme(), "Scheme should be DOMAIN_NAME"); + $address = $endpoints->getAddresses()[0]; + $this->assertEquals('rocketmq.apache.org', $address->getHost(), "HTTPS prefix should be stripped"); + $this->assertEquals(8081, $address->getPort(), "Port should be 8081"); + } +} + diff --git a/php/tests/ExponentialBackoffRetryPolicyTest.php b/php/tests/ExponentialBackoffRetryPolicyTest.php new file mode 100644 index 000000000..63aedb1ed --- /dev/null +++ b/php/tests/ExponentialBackoffRetryPolicyTest.php @@ -0,0 +1,88 @@ +assertEquals(3, $policy->getMaxAttempts(), "Default max attempts should be 3"); + + $delay1 = $policy->getNextDelayMs(1); + $this->assertEquals(1000, $delay1, "Attempt 1 delay should be base (1000ms)"); + + $delay2 = $policy->getNextDelayMs(2); + $this->assertEquals(2000, $delay2, "Attempt 2 delay should be 2000ms (1000 * 2)"); + } + + public function testExponentialGrowth() + { + $policy = new ExponentialBackoffRetryPolicy(5, 100, 10000, 2.0); + + $this->assertEquals(100, $policy->getNextDelayMs(1), "Attempt 1 should be 100ms"); + $this->assertEquals(200, $policy->getNextDelayMs(2), "Attempt 2 should be 200ms"); + $this->assertEquals(400, $policy->getNextDelayMs(3), "Attempt 3 should be 400ms"); + $this->assertEquals(800, $policy->getNextDelayMs(4), "Attempt 4 should be 800ms"); + $this->assertEquals(0, $policy->getNextDelayMs(5), "Attempt 5 should be 0 (at max)"); + } + + public function testMaxDelayCap() + { + $policy = new ExponentialBackoffRetryPolicy(10, 1000, 5000, 3.0); + + $delay1 = $policy->getNextDelayMs(1); + $this->assertEquals(1000, $delay1, "Attempt 1 should be 1000ms"); + + $delay2 = $policy->getNextDelayMs(2); + $this->assertEquals(3000, $delay2, "Attempt 2 should be 3000ms"); + + $delay3 = $policy->getNextDelayMs(3); + $this->assertEquals(5000, $delay3, "Attempt 3 should be capped at 5000ms"); + } + + public function testWithJitter() + { + $policy = new ExponentialBackoffRetryPolicy(5, 1000, 10000, 2.0); + + $jitter = $policy->getNextDelayWithJitterMs(1); + // Jitter range is 50%-100% of base, allow small tolerance for edge cases + $minExpected = (int)(1000 * 0.49); + $maxExpected = 1000; + $this->assertTrue( + $jitter >= $minExpected && $jitter <= $maxExpected, + "Jitter should be between 50%% and 100%% of base ({$minExpected}-{$maxExpected}). Got: {$jitter}" + ); + } + + public function testRejectsNegativeMaxAttempts() + { + $this->expectException(\InvalidArgumentException::class); + new ExponentialBackoffRetryPolicy(0); + } +} diff --git a/php/tests/FifoConsumeServiceTest.php b/php/tests/FifoConsumeServiceTest.php new file mode 100644 index 000000000..86f80d972 --- /dev/null +++ b/php/tests/FifoConsumeServiceTest.php @@ -0,0 +1,171 @@ +systemProperties = new FifoFakeSystemProps($receiptHandle); + } + + public function getBody(): string + { + return $this->body; + } + + public function getTopic(): string + { + return $this->topic; + } + + public function getReceiptHandle(): ?string + { + return $this->receiptHandle; + } + + public function getMessageGroup(): ?string + { + return null; + } + + public function getSystemProperties(): ?object + { + return $this->systemProperties; + } + + public function getMessageId(): string + { + return ''; + } + + public function getDeliveryAttempt(): int { return 1; } + public function incrementDeliveryAttempt(): void {} + public function isCorrupted(): bool { return false; } + public function getEndpoints(): ?object { return null; } +} + +class FifoFakeSystemProps +{ + private ?string $receiptHandle; + + public function __construct(?string $receiptHandle = null) + { + $this->receiptHandle = $receiptHandle; + } + + public function getReceiptHandle(): ?string + { + return $this->receiptHandle; + } + + public function hasReceiptHandle(): bool + { + return $this->receiptHandle !== null; + } + + public function hasMessageGroup(): bool + { + return false; + } +} + +class FifoFakeConsumerForConsume extends FakeConsumer +{ + public function __construct() + { + parent::__construct('test-fifo-consumer'); + } +} + +class FifoConsumeServiceTest extends TestCase +{ + public function setUp(): void + { + \Apache\Rocketmq\Logger::close(); + } + + public function testConsumeMessageSingleMessage() + { + $fakeConsumer = new FifoFakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('FifoConsumeSingle'); + + $listener = function($msg) { return ConsumeResult::SUCCESS; }; + $service = new FifoConsumeService($logger, $listener, $fakeConsumer, false); + + $method = new \ReflectionMethod($service, 'consumeMessage'); + $method->setAccessible(true); + $result = $method->invoke($service, new FifoFakeMessageView('msg', 'topic')); + + $this->assertEquals(ConsumeResult::SUCCESS, $result, "consumeMessage should return SUCCESS"); + } + + public function testDefaultGroupKeyForNonGroupedMessages() + { + $fakeConsumer = new FifoFakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('FifoConsumeDefaultKey'); + + $service = new FifoConsumeService($logger, function($msg) { return ConsumeResult::SUCCESS; }, $fakeConsumer, false); + $msg = new FifoFakeMessageView('msg', 'topic'); + + $method = new \ReflectionMethod($service, 'getMessageGroupKey'); + $method->setAccessible(true); + $groupKey = $method->invoke($service, $msg); + + $this->assertEquals('default', $groupKey, "Non-grouped message should have 'default' group key"); + } + + public function testExtractReceiptHandle() + { + $fakeConsumer = new FifoFakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('FifoConsumeExtract'); + + $listener = function($msg) { return ConsumeResult::SUCCESS; }; + $service = new FifoConsumeService($logger, $listener, $fakeConsumer, false); + + $msgWithHandle = new FifoFakeMessageView('body', 'topic', 'receipt-handle-789'); + + $method = new \ReflectionMethod($service, 'extractReceiptHandle'); + $method->setAccessible(true); + $handle = $method->invoke($service, $msgWithHandle); + + $this->assertEquals('receipt-handle-789', $handle, "Should extract receipt handle"); + } +} diff --git a/php/tests/LitePushConsumerTest.php b/php/tests/LitePushConsumerTest.php new file mode 100644 index 000000000..fb132dde4 --- /dev/null +++ b/php/tests/LitePushConsumerTest.php @@ -0,0 +1,191 @@ +expectException(\InvalidArgumentException::class); + new LitePushConsumer('127.0.0.1:9876', '', 'parent-topic'); + } + + /** + * Mirrors Java: testBindTopicWithNull + */ + public function testConstructorWithNullParentTopic() + { + $this->expectException(\InvalidArgumentException::class); + new LitePushConsumer('127.0.0.1:9876', 'test-group', ''); + } + + /** + * Mirrors Java: testSetMessageListenerWithNull + */ + public function testStartWithoutMessageListener() + { + $consumer = new LitePushConsumer('127.0.0.1:9876', 'test-group', 'parent-topic', []); + + $this->expectException(\RuntimeException::class); + $consumer->start(); + } + + /** + * Mirrors Java: testSetNegativeMaxCacheMessageCount + */ + public function testNegativeMaxCacheMessageCount() + { + $consumer = new LitePushConsumer('127.0.0.1:9876', 'test-group', 'parent-topic', [ + 'maxCacheMessageCount' => -1, + 'messageListener' => function($msg) { return 0; }, + ]); + + $threshold = $consumer->getCacheMessageCountThresholdPerQueue(); + $this->assertTrue( + $threshold >= 0, + "Cache count threshold should be non-negative (got {$threshold})" + ); + } + + /** + * Mirrors Java: testSetNegativeMaxCacheMessageSizeInBytes + */ + public function testNegativeMaxCacheMessageSizeInBytes() + { + $consumer = new LitePushConsumer('127.0.0.1:9876', 'test-group', 'parent-topic', [ + 'maxCacheMessageSizeInBytes' => -1, + 'messageListener' => function($msg) { return 0; }, + ]); + + $threshold = $consumer->getCacheMessageBytesThresholdPerQueue(); + $this->assertTrue( + $threshold >= 0, + "Cache bytes threshold should be non-negative (got {$threshold})" + ); + } + + /** + * Mirrors Java: testBuildWithoutClientConfiguration + */ + public function testStartWithoutLiteTopics() + { + $consumer = new LitePushConsumer('127.0.0.1:9876', 'test-group', 'parent-topic', [ + 'messageListener' => function($msg) { return 0; }, + ]); + + $this->expectException(\RuntimeException::class); + $consumer->start(); + } + + /** + * Tests that subscribeLite works before start. + */ + public function testSubscribeLiteBeforeStart() + { + $consumer = new LitePushConsumer('127.0.0.1:9876', 'test-group', 'parent-topic', [ + 'messageListener' => function($msg) { return 0; }, + ]); + + $consumer->subscribeLite('lite-topic-1', function($msg) { return 0; }); + $consumer->subscribeLite('lite-topic-2'); + + $topics = $consumer->getLiteTopics(); + $this->assertEquals( + 2, + count($topics), + "Should have 2 lite topics" + ); + $this->assertTrue( + in_array('lite-topic-1', $topics), + "lite-topic-1 should be in topics" + ); + $this->assertTrue( + in_array('lite-topic-2', $topics), + "lite-topic-2 should be in topics" + ); + } + + /** + * Tests unsubscribeLite before start. + */ + public function testUnsubscribeLiteBeforeStart() + { + $consumer = new LitePushConsumer('127.0.0.1:9876', 'test-group', 'parent-topic', [ + 'messageListener' => function($msg) { return 0; }, + ]); + + $consumer->subscribeLite('lite-topic', function($msg) { return 0; }); + $consumer->unsubscribeLite('lite-topic'); + + $topics = $consumer->getLiteTopics(); + $this->assertTrue( + empty($topics), + "Lite topics should be empty after unsubscribe" + ); + } + + /** + * Tests that lite topic name length is validated. + */ + public function testLiteTopicNameTooLong() + { + $consumer = new LitePushConsumer('127.0.0.1:9876', 'test-group', 'parent-topic', [ + 'maxLiteTopicSize' => 10, + 'messageListener' => function($msg) { return 0; }, + ]); + + $this->expectException(\RuntimeException::class); + $consumer->subscribeLite('this-is-a-very-long-lite-topic-name', function($msg) { return 0; }); + } + + /** + * Tests isLiteConsumer returns true. + */ + public function testIsLiteConsumer() + { + $consumer = new LitePushConsumer('127.0.0.1:9876', 'test-group', 'parent-topic', [ + 'messageListener' => function($msg) { return 0; }, + ]); + + $this->assertTrue( + $consumer->isLiteConsumer(), + "LitePushConsumer should return true for isLiteConsumer" + ); + } +} diff --git a/php/tests/LoggerTest.php b/php/tests/LoggerTest.php new file mode 100644 index 000000000..5695d77ea --- /dev/null +++ b/php/tests/LoggerTest.php @@ -0,0 +1,141 @@ +testLogFile = sys_get_temp_dir() . '/rocketmq_test_logger_' . uniqid() . '.log'; + } + + public function tearDown(): void + { + Logger::close(); + if (file_exists($this->testLogFile)) { + @unlink($this->testLogFile); + } + } + + public function testSingleton() + { + Logger::setLogFile($this->testLogFile); + $logger1 = Logger::getInstance('TestComponent'); + $logger2 = Logger::getInstance('TestComponent'); + + $this->assertTrue($logger1 === $logger2, "Should return same instance for same component"); + } + + public function testDifferentComponents() + { + Logger::setLogFile($this->testLogFile); + + $logger1 = Logger::getInstance('ComponentA'); + $logger2 = Logger::getInstance('ComponentB'); + + $this->assertTrue($logger1 !== $logger2, "Should return different instances for different components"); + } + + public function testLogLevelFiltering() + { + Logger::setLogFile($this->testLogFile); + Logger::setLogLevel(Logger::LEVEL_ERROR); + + $logger = Logger::getInstance('LogLevelTest'); + $logger->debug('This should not be logged'); + $logger->info('This should not be logged'); + $logger->warning('This should not be logged'); + + $content = ''; + if (file_exists($this->testLogFile)) { + $content = file_get_contents($this->testLogFile) ?: ''; + } + $this->assertFalse( + strpos($content, 'This should not be logged') !== false, + "DEBUG/INFO/WARNING should be filtered when log level is ERROR" + ); + } + + public function testLogLevelWrite() + { + Logger::setLogFile($this->testLogFile); + Logger::setLogLevel(Logger::LEVEL_DEBUG); + + $logger = Logger::getInstance('LogLevelWriteTest'); + $marker = 'unique_test_marker_' . uniqid(); + $logger->info($marker); + + $content = file_get_contents($this->testLogFile) ?: ''; + $this->assertTrue( + strpos($content, $marker) !== false, + "INFO message should be written when log level is DEBUG" + ); + } + + public function testLogFormat() + { + Logger::setLogFile($this->testLogFile); + Logger::setLogLevel(Logger::LEVEL_DEBUG); + + $marker = 'format_test_' . uniqid(); + $logger = Logger::getInstance('FormatTest'); + $logger->info($marker); + + $content = file_get_contents($this->testLogFile) ?: ''; + $expectedPattern = "/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] \[INFO\] \[FormatTest\] {$marker}/"; + $this->assertTrue( + preg_match($expectedPattern, $content) === 1, + "Log line should match expected format" + ); + } + + public function testTimezoneDetection() + { + $originalTz = date_default_timezone_get(); + + // Force UTC to simulate PHP CLI default + date_default_timezone_set('UTC'); + + // Logger init should detect and correct system timezone + Logger::getInstance('TimezoneTest'); + + $currentTz = date_default_timezone_get(); + + // Restore original timezone + if ($originalTz !== 'UTC') { + date_default_timezone_set($originalTz); + } + + // If system timezone was detected (not UTC anymore), the test passes + // If it's still UTC, that's also valid (system may genuinely be UTC) + $this->assertTrue( + in_array($currentTz, timezone_identifiers_list(), true) || $currentTz === 'UTC', + "Timezone should be a valid identifier after Logger init (got: {$currentTz})" + ); + } +} diff --git a/php/tests/MessageBuilderEncodingTest.php b/php/tests/MessageBuilderEncodingTest.php new file mode 100644 index 000000000..4d7d7400b --- /dev/null +++ b/php/tests/MessageBuilderEncodingTest.php @@ -0,0 +1,123 @@ +setTopic($this->topic) + ->setBody($this->body) + ->build(); + + $this->assertEquals( + $this->body, + $message->getBody(), + "Message body should be unchanged when no encoding is set" + ); + } + + public function testBuildWithGzipEncodingCompressesBody() + { + $message = (new MessageBuilder()) + ->setTopic($this->topic) + ->setBody($this->body) + ->setEncoding(Utilities::ENCODING_GZIP_STR) + ->build(); + + $encodedBody = $message->getBody(); + $this->assertTrue( + $encodedBody !== $this->body, + "GZIP-encoded body should differ from original" + ); + + $decompressed = Utilities::decompressBytes($encodedBody, Utilities::ENCODING_GZIP); + $this->assertEquals( + $this->body, + $decompressed, + "GZIP-encoded body should decompress back to original" + ); + } + + public function testBuildWithZlibEncodingCompressesBody() + { + $message = (new MessageBuilder()) + ->setTopic($this->topic) + ->setBody($this->body) + ->setEncoding(Utilities::ENCODING_ZLIB_STR) + ->build(); + + $encodedBody = $message->getBody(); + $this->assertTrue( + $encodedBody !== $this->body, + "ZLIB-encoded body should differ from original" + ); + + $decompressed = Utilities::decompressBytes($encodedBody, Utilities::ENCODING_ZLIB); + $this->assertEquals( + $this->body, + $decompressed, + "ZLIB-encoded body should decompress back to original" + ); + } + + public function testBuildWithIdentityEncodingReturnsPlainBody() + { + $message = (new MessageBuilder()) + ->setTopic($this->topic) + ->setBody($this->body) + ->setEncoding(Utilities::ENCODING_IDENTITY_STR) + ->build(); + + $this->assertEquals( + $this->body, + $message->getBody(), + "IDENTITY encoding should leave body unchanged" + ); + } + + public function testSetEncodingReturnsFluentBuilder() + { + $builder = new MessageBuilder(); + $result = $builder->setTopic($this->topic) + ->setBody($this->body) + ->setEncoding(Utilities::ENCODING_GZIP_STR); + + $this->assertTrue( + $result instanceof MessageBuilder, + "setEncoding should return the builder for chaining" + ); + } +} diff --git a/php/tests/MessageBuilderTest.php b/php/tests/MessageBuilderTest.php new file mode 100644 index 000000000..765d97eff --- /dev/null +++ b/php/tests/MessageBuilderTest.php @@ -0,0 +1,286 @@ +setTopic('test-topic') + ->setBody('hello world') + ->build(); + + $this->assertEquals('test-topic', $msg->getTopic()->getName(), "Topic should be set"); + $this->assertEquals('hello world', $msg->getBody(), "Body should be set"); + $this->assertFalse($msg->hasSystemProperties(), "Should have no system properties for minimal message"); + } + + public function testBuildMessageWithTag() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->setTag('my-tag') + ->build(); + + $this->assertTrue($msg->hasSystemProperties(), "Should have system properties"); + $this->assertEquals('my-tag', $msg->getSystemProperties()->getTag(), "Tag should match"); + } + + public function testBuildMessageWithKeys() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->setKeys(['key1', 'key2']) + ->build(); + + $keys = $msg->getSystemProperties()->getKeys(); + $this->assertEquals(2, count($keys), "Should have 2 keys"); + } + + public function testBuildFifoMessage() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->setMessageGroup('order-group-1') + ->build(); + + $sysProps = $msg->getSystemProperties(); + $this->assertTrue($sysProps->hasMessageGroup(), "Should have message group"); + $this->assertEquals('order-group-1', $sysProps->getMessageGroup(), "Message group should match"); + } + + public function testBuildDelayMessage() + { + $deliveryTimeMs = (time() + 60) * 1000; + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->setDeliveryTimestamp($deliveryTimeMs) + ->build(); + + $sysProps = $msg->getSystemProperties(); + $this->assertTrue($sysProps->hasDeliveryTimestamp(), "Should have delivery timestamp"); + } + + public function testBuildPriorityMessage() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->setPriority(1) + ->build(); + + $sysProps = $msg->getSystemProperties(); + $this->assertTrue($sysProps->hasPriority(), "Should have priority"); + $this->assertEquals(1, $sysProps->getPriority(), "Priority should be 1"); + } + + public function testBuildLiteMessage() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->setLiteTopic('lite-subtopic') + ->build(); + + $sysProps = $msg->getSystemProperties(); + $this->assertTrue($sysProps->hasLiteTopic(), "Should have lite topic"); + } + + public function testBuildMessageWithUserProperties() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->addProperty('custom-key', 'custom-value') + ->addProperty('another-key', 'another-value') + ->build(); + + $props = $msg->getUserProperties(); + $this->assertEquals('custom-value', $props['custom-key'], "User property should match"); + $this->assertEquals(2, count($props), "Should have 2 user properties"); + } + + public function testBuildFullMessage() + { + $msg = (new MessageBuilder()) + ->setTopic('full-topic') + ->setBody('full body content') + ->setTag('full-tag') + ->setKeys(['key-a', 'key-b']) + ->addProperty('env', 'production') + ->build(); + + $this->assertEquals('full-topic', $msg->getTopic()->getName(), "Topic should match"); + $this->assertEquals('full body content', $msg->getBody(), "Body should match"); + $this->assertEquals('full-tag', $msg->getSystemProperties()->getTag(), "Tag should match"); + } + + public function testBuilderReturnsThisForChaining() + { + $builder = new MessageBuilder(); + $result = $builder->setTopic('test'); + $this->assertTrue($result === $builder, "setTopic should return \$this"); + } + + // --- Validation tests (mirrors Java MessageImplTest) --- + + public function testRejectsEmptyTopic() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic('')->setBody('body')->build(); + } + + public function testRejectsMissingTopic() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setBody('body')->build(); + } + + public function testRejectsMissingBody() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic('test')->build(); + } + + public function testRejectsTagWithVerticalBar() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic('test')->setBody('body')->setTag('|')->build(); + } + + public function testRejectsTagWithWhitespace() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic('test')->setBody('body')->setTag("tag value")->build(); + } + + public function testRejectsBlankKey() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic('test')->setBody('body')->addKey(' ')->build(); + } + + public function testRejectsBlankLiteTopic() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic('test')->setBody('body')->setLiteTopic(' ')->build(); + } + + public function testRejectsInvalidPriority() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic('test')->setBody('body')->setPriority(0)->build(); + + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic('test')->setBody('body')->setPriority(10)->build(); + } + + public function testRejectsMessageTypeConflict() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder()) + ->setTopic('test') + ->setBody('body') + ->setDeliveryTimestamp(time() * 1000) + ->setMessageGroup('group') + ->build(); + + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder()) + ->setTopic('test') + ->setBody('body') + ->setMessageGroup('group') + ->setLiteTopic('lite') + ->build(); + + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder()) + ->setTopic('test') + ->setBody('body') + ->setPriority(1) + ->setDeliveryTimestamp(time() * 1000) + ->build(); + } + + public function testTopicSetterWithEmptyString() + { + $this->expectException(\InvalidArgumentException::class); + (new MessageBuilder())->setTopic(' ')->build(); + } + + public function testTagSetterReturnsValidTag() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->setTag('tagA') + ->build(); + + $this->assertTrue($msg->hasSystemProperties(), "Should have system properties"); + $this->assertEquals('tagA', $msg->getSystemProperties()->getTag(), "Tag should be tagA"); + } + + public function testKeySetterValidKey() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->setKeys(['keyA']) + ->build(); + + $keys = $msg->getSystemProperties()->getKeys(); + $this->assertTrue(count($keys) > 0, "Should have at least 1 key"); + } + + public function testBuildNoOptionalFields() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->build(); + + $this->assertFalse($msg->hasSystemProperties(), "Should not have system properties"); + } + + public function testMultiplePropertiesAdd() + { + $msg = (new MessageBuilder()) + ->setTopic('test-topic') + ->setBody('body') + ->addProperty('foo', 'value') + ->addProperty('bar', 'value2') + ->build(); + + $props = $msg->getUserProperties(); + $this->assertEquals(2, count($props), "Should have 2 user properties"); + $this->assertEquals('value', $props['foo'], "Property 'foo' should match"); + $this->assertEquals('value2', $props['bar'], "Property 'bar' should match"); + } +} diff --git a/php/tests/MessageIdCodecTest.php b/php/tests/MessageIdCodecTest.php new file mode 100644 index 000000000..762adcb8b --- /dev/null +++ b/php/tests/MessageIdCodecTest.php @@ -0,0 +1,79 @@ +codec = MessageIdCodec::getInstance(); + } + + public function testNextMessageId() + { + $messageId = $this->codec->nextMessageId(); + $this->assertEquals( + MessageIdCodec::MESSAGE_ID_LENGTH_FOR_V1_OR_LATER, + strlen($messageId->toString()), + "Message ID length should be " . MessageIdCodec::MESSAGE_ID_LENGTH_FOR_V1_OR_LATER + ); + } + + public function testNextMessageIdWithNoRepetition() + { + $messageIds = []; + $messageIdCount = 64; + for ($i = 0; $i < $messageIdCount; $i++) { + $id = $this->codec->nextMessageId()->toString(); + $messageIds[$id] = true; + } + $this->assertEquals( + $messageIdCount, + count($messageIds), + "All {$messageIdCount} message IDs should be unique" + ); + } + + public function testDecode() + { + $messageIdString = "0156F7E71C361B21BC024CCDBE00000000"; + $messageId = $this->codec->decode($messageIdString); + $this->assertEquals( + MessageIdCodec::MESSAGE_ID_VERSION_V1, + $messageId->getVersion(), + "Version should be V1" + ); + $this->assertEquals( + $messageIdString, + $messageId->toString(), + "Decoded message ID should match original" + ); + } +} diff --git a/php/tests/MessageIdImplTest.php b/php/tests/MessageIdImplTest.php new file mode 100644 index 000000000..cdb31d3c3 --- /dev/null +++ b/php/tests/MessageIdImplTest.php @@ -0,0 +1,95 @@ +assertEquals( + '0156F7E71C361B21BC024CCDBE00000000', + $messageId->toString(), + "V0 toString should return suffix directly" + ); + } + + public function testToStringV1() + { + $codec = MessageIdCodec::getInstance(); + $messageId = new \Apache\Rocketmq\MessageIdImpl( + MessageIdCodec::MESSAGE_ID_VERSION_V1, + '56F7E71C361B21BC024CCDBE00000000' + ); + + $this->assertEquals( + '0156F7E71C361B21BC024CCDBE00000000', + $messageId->toString(), + "V1 toString should prefix with version" + ); + } + + public function testEquals() + { + $id1 = new \Apache\Rocketmq\MessageIdImpl('01', 'ABC123'); + $id2 = new \Apache\Rocketmq\MessageIdImpl('01', 'ABC123'); + $id3 = new \Apache\Rocketmq\MessageIdImpl('01', 'DEF456'); + + $this->assertTrue($id1->equals($id2), "Same version and suffix should be equal"); + $this->assertFalse($id1->equals($id3), "Different suffix should not be equal"); + $this->assertTrue($id1->equals($id1), "Same instance should be equal"); + $this->assertFalse($id1->equals(null), "Null should not be equal"); + } + + public function testHashCode() + { + $id1 = new \Apache\Rocketmq\MessageIdImpl('01', 'ABC123'); + $id2 = new \Apache\Rocketmq\MessageIdImpl('01', 'ABC123'); + + $this->assertEquals( + $id1->hashCode(), + $id2->hashCode(), + "Equal IDs should have same hash code" + ); + } + + public function testDupeString() + { + $id = new \Apache\Rocketmq\MessageIdImpl('01', 'ABC123'); + $this->assertEquals( + '01ABC123', + (string)$id, + "__toString should return toString" + ); + } +} + diff --git a/php/tests/MessageTest.php b/php/tests/MessageTest.php new file mode 100644 index 000000000..bdc9100dc --- /dev/null +++ b/php/tests/MessageTest.php @@ -0,0 +1,169 @@ +setName('test-topic'); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody('test body content'); + + $this->assertEquals('test-topic', $message->getTopic()->getName(), "Topic name should match"); + $this->assertEquals('test body content', $message->getBody(), "Body should match"); + } + + public function testMessageWithTag() + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $sysProps = new SystemProperties(); + $sysProps->setTag('test-tag'); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody('body'); + $message->setSystemProperties($sysProps); + + $props = $message->getSystemProperties(); + $this->assertTrue($props->hasTag(), "Should have tag set"); + $this->assertEquals('test-tag', $props->getTag(), "Tag should match"); + } + + public function testMessageWithKeys() + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $sysProps = new SystemProperties(); + $sysProps->setKeys(['key1', 'key2']); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody('body'); + $message->setSystemProperties($sysProps); + + $props = $message->getSystemProperties(); + $keys = $props->getKeys(); + $this->assertEquals(2, count($keys), "Should have 2 keys"); + $this->assertEquals('key1', $keys[0], "First key should match"); + } + + public function testMessageWithMessageGroup() + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $sysProps = new SystemProperties(); + $sysProps->setMessageGroup('group-A'); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody('body'); + $message->setSystemProperties($sysProps); + + $props = $message->getSystemProperties(); + $this->assertTrue($props->hasMessageGroup(), "Should have message group"); + $this->assertEquals('group-A', $props->getMessageGroup(), "Message group should match"); + } + + public function testMessageWithUserProperties() + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody('body'); + $message->getUserProperties()['custom-key'] = 'custom-value'; + + $this->assertEquals( + 'custom-value', + $message->getUserProperties()['custom-key'], + "User property should match" + ); + } + + public function testMessageBodyImmutability() + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $originalBody = 'original body'; + $message = new Message(); + $message->setTopic($topic); + $message->setBody($originalBody); + + // Modify original variable + $originalBody = 'modified'; + + // Message body should remain unchanged + $this->assertEquals('original body', $message->getBody(), "Message body should be immutable after set"); + } + + public function testMessageWithPriority() + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $sysProps = new SystemProperties(); + $sysProps->setPriority(1); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody('body'); + $message->setSystemProperties($sysProps); + + $props = $message->getSystemProperties(); + $this->assertTrue($props->hasPriority(), "Should have priority set"); + $this->assertEquals(1, $props->getPriority(), "Priority should be 1"); + } + + public function testMessageWithLiteTopic() + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $sysProps = new SystemProperties(); + $sysProps->setLiteTopic('lite-topic-A'); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody('body'); + $message->setSystemProperties($sysProps); + + $props = $message->getSystemProperties(); + $this->assertTrue($props->hasLiteTopic(), "Should have lite topic"); + $this->assertEquals('lite-topic-A', $props->getLiteTopic(), "Lite topic should match"); + } +} diff --git a/php/tests/MessageViewExtendedTest.php b/php/tests/MessageViewExtendedTest.php new file mode 100644 index 000000000..971d128e2 --- /dev/null +++ b/php/tests/MessageViewExtendedTest.php @@ -0,0 +1,236 @@ +buildMessage(); + $view = new MessageView($msg, null, null, 1); + + $this->assertTrue( + $view->getBornTimestamp() >= 0, + "Born timestamp should be non-negative" + ); + } + + public function testBornHost() + { + $msg = $this->buildMessage(); + $view = new MessageView($msg, null, null, 1); + + $this->assertTrue( + is_string($view->getBornHost()), + "Born host should be a string" + ); + } + + public function testDecodeTimestamp() + { + $msg = $this->buildMessage(); + $view = new MessageView($msg, null, null, 1); + $beforeDecode = $view->getDecodeTimestamp(); + + $this->assertTrue( + $beforeDecode > 0, + "Decode timestamp should be positive" + ); + } + + public function testIncrementDeliveryAttempt() + { + $msg = $this->buildMessage(); + $view = new MessageView($msg, null, null, 1); + + $this->assertEquals( + 1, + $view->getDeliveryAttempt(), + "Initial delivery attempt should be 1" + ); + + $view->incrementDeliveryAttempt(); + $this->assertEquals( + 2, + $view->getDeliveryAttempt(), + "After increment, delivery attempt should be 2" + ); + + $view->incrementDeliveryAttempt(); + $this->assertEquals( + 3, + $view->getDeliveryAttempt(), + "After second increment, delivery attempt should be 3" + ); + } + + public function testDeliveryAttemptMinimum() + { + $msg = $this->buildMessage(); + $view = new MessageView($msg, null, null, 0); + + $this->assertTrue( + $view->getDeliveryAttempt() >= 1, + "Delivery attempt should be at least 1 even if passed 0" + ); + } + + public function testGetProperty() + { + $message = new Message(); + $message->setBody('hello'); + $topic = new Resource(); + $topic->setName('test-topic'); + $message->setTopic($topic); + $message->getUserProperties()['env'] = 'production'; + + $view = new MessageView($message); + + $this->assertEquals( + 'production', + $view->getProperty('env'), + "getProperty should return the correct value" + ); + $this->assertNull( + $view->getProperty('nonexistent'), + "getProperty should return null for missing key" + ); + } + + public function testGetPropertiesReturnsNativeArray() + { + $message = new Message(); + $message->setBody('hello'); + $topic = new Resource(); + $topic->setName('test-topic'); + $message->setTopic($topic); + $map = $message->getUserProperties(); + $map['key1'] = 'val1'; + $map['key2'] = 'val2'; + + $view = new MessageView($message); + $props = $view->getProperties(); + + $this->assertTrue( + is_array($props), + "getProperties should return a native PHP array" + ); + $this->assertEquals( + 'val1', + $props['key1'], + "Property key1 should be accessible" + ); + } + + public function testGetKeysReturnsNativeArray() + { + $message = $this->buildMessage(); + $sysProps = new SystemProperties(); + $sysProps->setKeys(['order-123', 'user-456']); + $message->setSystemProperties($sysProps); + + $view = new MessageView($message); + $keys = $view->getKeys(); + + $this->assertTrue( + is_array($keys), + "getKeys should return a native PHP array" + ); + $this->assertEquals( + 2, + count($keys), + "Should have 2 keys" + ); + } + + public function testToStringIncludesTopic() + { + $msg = $this->buildMessage(); + $view = new MessageView($msg, null, null, 1); + $str = (string)$view; + + $this->assertTrue( + strpos($str, 'test-topic') !== false, + "toString should include topic name" + ); + } + + public function testToStringIncludesMessageId() + { + $msg = $this->buildMessage(); + $view = new MessageView($msg, null, null, 1); + $str = (string)$view; + + $this->assertTrue( + strpos($str, 'msg-abc-123') !== false, + "toString should include message ID" + ); + } + + public function testToStringIncludesCorrupted() + { + $msg = $this->buildMessage(); + $view = new MessageView($msg, null, null, 1); + + // Use reflection to set corrupted flag + $ref = new \ReflectionProperty($view, 'corrupted'); + $ref->setAccessible(true); + $ref->setValue($view, true); + + $str = (string)$view; + + $this->assertTrue( + strpos($str, 'CORRUPTED') !== false, + "toString should include CORRUPTED marker" + ); + } + + /** + * Helper to build a base Message. + */ + private function buildMessage() + { + $message = new Message(); + $message->setBody('hello world'); + $topic = new Resource(); + $topic->setName('test-topic'); + $message->setTopic($topic); + + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-abc-123'); + $sysProps->setTag('test-tag'); + $message->setSystemProperties($sysProps); + + return $message; + } +} diff --git a/php/tests/MessageViewIntegrityTest.php b/php/tests/MessageViewIntegrityTest.php new file mode 100644 index 000000000..a80eed30b --- /dev/null +++ b/php/tests/MessageViewIntegrityTest.php @@ -0,0 +1,226 @@ +buildMessage($body, Encoding::IDENTITY, DigestType::CRC32, $crc32); + $view = new MessageView($message); + + $this->assertEquals($body, $view->getBody(), "Body should match original"); + $this->assertFalse($view->isCorrupted(), "Message should NOT be corrupted with correct CRC32"); + } + + /** + * Mirrors Java: testFromProtobufWithWrongCrc32 + */ + public function testWrongCrc32Digest() + { + $body = 'foobar'; + + $message = $this->buildMessage($body, Encoding::IDENTITY, DigestType::CRC32, '9EF61F96'); + $view = new MessageView($message); + + $this->assertTrue($view->isCorrupted(), "Message should be corrupted with wrong CRC32"); + } + + /** + * Mirrors Java: testFromProtobufWithMd5 + */ + public function testCorrectMd5Digest() + { + $body = 'foobar'; + $md5 = strtoupper(md5($body)); + + $message = $this->buildMessage($body, Encoding::IDENTITY, DigestType::MD5, $md5); + $view = new MessageView($message); + + $this->assertEquals($body, $view->getBody(), "Body should match original"); + $this->assertFalse($view->isCorrupted(), "Message should NOT be corrupted with correct MD5"); + } + + /** + * Mirrors Java: testFromProtobufWithWrongMd5 + */ + public function testWrongMd5Digest() + { + $body = 'foobar'; + + $message = $this->buildMessage($body, Encoding::IDENTITY, DigestType::MD5, '3858F62230AC3C915F300C664312C63G'); + $view = new MessageView($message); + + $this->assertTrue($view->isCorrupted(), "Message should be corrupted with wrong MD5"); + } + + /** + * Mirrors Java: testFromProtobufWithSha1 + */ + public function testCorrectSha1Digest() + { + $body = 'foobar'; + $sha1 = strtoupper(sha1($body)); + + $message = $this->buildMessage($body, Encoding::IDENTITY, DigestType::SHA1, $sha1); + $view = new MessageView($message); + + $this->assertEquals($body, $view->getBody(), "Body should match original"); + $this->assertFalse($view->isCorrupted(), "Message should NOT be corrupted with correct SHA1"); + } + + /** + * Mirrors Java: testFromProtobufWithWrongSha1 + */ + public function testWrongSha1Digest() + { + $body = 'foobar'; + + $message = $this->buildMessage($body, Encoding::IDENTITY, DigestType::SHA1, '8843D7F92416211DE9EBB963FF4CE28125932879'); + $view = new MessageView($message); + + $this->assertTrue($view->isCorrupted(), "Message should be corrupted with wrong SHA1"); + } + + /** + * Tests GZIP compressed body with correct CRC32. + */ + public function testGzipBodyWithCorrectCrc32() + { + $body = 'hello world'; + $compressed = gzencode($body); + // The digest covers the encoded (compressed) body bytes, not the decoded payload + $crc32 = Utilities::crc32CheckSum($compressed); + + $message = $this->buildMessage($compressed, Encoding::GZIP, DigestType::CRC32, $crc32); + $view = new MessageView($message); + + $this->assertEquals($body, $view->getBody(), "Body should be decompressed correctly"); + $this->assertFalse($view->isCorrupted(), "GZIP message should NOT be corrupted with correct CRC32"); + } + + /** + * Tests GZIP compressed body with wrong CRC32. + */ + public function testGzipBodyWithWrongCrc32() + { + $body = 'hello world'; + $compressed = gzencode($body); + + $message = $this->buildMessage($compressed, Encoding::GZIP, DigestType::CRC32, 'WRONG_CRC32'); + $view = new MessageView($message); + + $this->assertTrue($view->isCorrupted(), "GZIP message should be corrupted with wrong CRC32"); + } + + /** + * A digest computed over the decoded payload must NOT match: the digest + * covers the encoded (compressed) body bytes and is verified before + * decompression. + */ + public function testGzipBodyDigestOverDecodedBodyIsCorrupted() + { + $body = 'hello world'; + $compressed = gzencode($body); + $wrongCrc32 = Utilities::crc32CheckSum($body); + + $message = $this->buildMessage($compressed, Encoding::GZIP, DigestType::CRC32, $wrongCrc32); + $view = new MessageView($message); + + $this->assertTrue($view->isCorrupted(), "Digest over decoded body should not match encoded body digest"); + $this->assertEquals($body, $view->getBody(), "Body should still be decompressed"); + } + + /** + * Tests empty body should not be corrupted. + */ + public function testEmptyBody() + { + $message = $this->buildMessage('', Encoding::IDENTITY, null, ''); + $view = new MessageView($message); + + $this->assertEquals('', $view->getBody(), "Body should be empty"); + $this->assertFalse($view->isCorrupted(), "Empty body message should NOT be corrupted"); + } + + /** + * Tests message with no digest should not be corrupted. + */ + public function testNoDigest() + { + $body = 'test data'; + $message = $this->buildMessage($body, Encoding::IDENTITY, null, null); + $view = new MessageView($message); + + $this->assertEquals($body, $view->getBody(), "Body should match original"); + $this->assertFalse($view->isCorrupted(), "Message without digest should NOT be corrupted"); + } + + /** + * Helper to build a protobuf Message with body and digest. + */ + private function buildMessage($body, $encoding, $digestType, $digestValue) + { + $message = new Message(); + $message->setBody($body); + + $topic = new Resource(); + $topic->setName('test-topic'); + $message->setTopic($topic); + + $sysProps = new SystemProperties(); + $sysProps->setMessageId('test-msg-id'); + $sysProps->setBodyEncoding($encoding); + + if ($digestType !== null && $digestValue !== null) { + $digest = new \Apache\Rocketmq\V2\Digest(); + $digest->setType($digestType); + $digest->setChecksum($digestValue); + $sysProps->setBodyDigest($digest); + } + + $message->setSystemProperties($sysProps); + return $message; + } +} diff --git a/php/tests/MessageViewTest.php b/php/tests/MessageViewTest.php new file mode 100644 index 000000000..c19a7b945 --- /dev/null +++ b/php/tests/MessageViewTest.php @@ -0,0 +1,172 @@ +setName('test-topic'); + + $sysProps = new \Apache\Rocketmq\V2\SystemProperties(); + $sysProps->setMessageId('test-msg-id-001'); + $sysProps->setTag('test-tag'); + $sysProps->setKeys(['key1']); + $sysProps->setMessageGroup('test-group'); + + $msg = new Message(); + $msg->setTopic($topic); + $msg->setBody('test body content'); + $msg->setSystemProperties($sysProps); + $msg->getUserProperties()['env'] = 'test'; + + return $msg; + } + + public function testGetTopic() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $this->assertEquals('test-topic', $view->getTopic(), "Topic should match"); + } + + public function testGetBody() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $this->assertEquals('test body content', $view->getBody(), "Body should match"); + } + + public function testGetMessageId() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $this->assertEquals('test-msg-id-001', $view->getMessageId(), "Message ID should match"); + } + + public function testGetTag() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $this->assertEquals('test-tag', $view->getTag(), "Tag should match"); + } + + public function testGetKeys() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $keys = $view->getKeys(); + $this->assertEquals(1, count($keys), "Should have 1 key"); + $this->assertEquals('key1', $keys[0], "Key should match"); + } + + public function testGetMessageGroup() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $this->assertEquals('test-group', $view->getMessageGroup(), "Message group should match"); + } + + public function testGetReceiptHandle() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg, 'receipt-handle-abc'); + + $this->assertEquals('receipt-handle-abc', $view->getReceiptHandle(), "Receipt handle should match"); + } + + public function testGetDeliveryAttempt() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg, null, null, 3); + + $this->assertEquals(3, $view->getDeliveryAttempt(), "Delivery attempt should be 3"); + } + + public function testGetUserProperties() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $this->assertEquals('test', $view->getProperty('env'), "User property should match"); + $this->assertNull($view->getProperty('nonexistent'), "Non-existent property should be null"); + } + + public function testIsFifo() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $this->assertTrue($view->isFifo(), "Should be FIFO when messageGroup is set"); + + // Non-FIFO message + $topic = new Resource(); + $topic->setName('normal-topic'); + $normalMsg = new Message(); + $normalMsg->setTopic($topic); + $normalMsg->setBody('normal body'); + + $normalView = new MessageView($normalMsg); + $this->assertFalse($normalView->isFifo(), "Should not be FIFO without messageGroup"); + } + + public function testToString() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $str = (string)$view; + $this->assertTrue( + strpos($str, 'test-topic') !== false, + "__toString should contain topic" + ); + $this->assertTrue( + strpos($str, 'test-msg-id-001') !== false, + "__toString should contain message ID" + ); + } + + public function testGetMessage() + { + $protoMsg = $this->createTestMessage(); + $view = new MessageView($protoMsg); + + $this->assertTrue( + $view->getMessage() === $protoMsg, + "getMessage should return the original protobuf message" + ); + } +} diff --git a/php/tests/Mocks/GrpcMocks.php b/php/tests/Mocks/GrpcMocks.php new file mode 100644 index 000000000..a99c49cf3 --- /dev/null +++ b/php/tests/Mocks/GrpcMocks.php @@ -0,0 +1,542 @@ +setCode(Code::OK); + $status->setMessage($message); + + return $status; + } + + /** + * Create a failed Status object + * + * @param int $code Error code + * @param string $message Error message + * @return Status + */ + public static function errorStatus(int $code = Code::INTERNAL_ERROR, string $message = 'Internal Error'): Status + { + $status = new Status(); + $status->setCode($code); + $status->setMessage($message); + + return $status; + } + + /** + * Create SendMessageResponse Mock object (success) + * + * @param array $entries Array of send result entries + * @param string $message Success message + * @return SendMessageResponse + */ + public static function mockSendMessageSuccess(array $entries = [], string $message = 'OK'): SendMessageResponse + { + $response = new SendMessageResponse(); + $response->setStatus(self::successStatus($message)); + + if (!empty($entries)) { + $response->setEntries($entries); + } + + return $response; + } + + /** + * Create SendMessageResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return SendMessageResponse + */ + public static function mockSendMessageError(int $code = Code::INTERNAL_ERROR, string $message = 'Failed to send message'): SendMessageResponse + { + $response = new SendMessageResponse(); + $response->setStatus(self::errorStatus($code, $message)); + $response->setEntries([]); + + return $response; + } + + /** + * Create a single SendResultEntry + * + * @param string $messageId Message ID + * @param string $transactionId Transaction ID (optional) + * @param int $code Status code + * @param string $errorMessage Error message (optional) + * @return SendResultEntry + */ + public static function mockSendResultEntry( + string $messageId, + string $transactionId = '', + int $code = Code::OK, + string $errorMessage = '' + ): SendResultEntry { + $entry = new SendResultEntry(); + $entry->setMessageId($messageId); + + if (!empty($transactionId)) { + $entry->setTransactionId($transactionId); + } + + if ($code !== Code::OK) { + $status = self::errorStatus($code, $errorMessage ?: 'Send failed'); + $entry->setStatus($status); + } + + return $entry; + } + + /** + * Create AckMessageResponse Mock object (success) + * + * @param array $entries Array of ACK result entries + * @param string $message Success message + * @return AckMessageResponse + */ + public static function mockAckMessageSuccess(array $entries = [], string $message = 'OK'): AckMessageResponse + { + $response = new AckMessageResponse(); + $response->setStatus(self::successStatus($message)); + + if (!empty($entries)) { + $response->setEntries($entries); + } + + return $response; + } + + /** + * Create AckMessageResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return AckMessageResponse + */ + public static function mockAckMessageError(int $code = Code::INVALID_RECEIPT_HANDLE, string $message = 'Failed to ack message'): AckMessageResponse + { + $response = new AckMessageResponse(); + $response->setStatus(self::errorStatus($code, $message)); + $response->setEntries([]); + + return $response; + } + + /** + * Create a single AckMessageResultEntry + * + * @param string $receiptHandle Receipt handle + * @param int $code Status code + * @param string $errorMessage Error message (optional) + * @return AckMessageResultEntry + */ + public static function mockAckMessageResultEntry( + string $receiptHandle, + int $code = Code::OK, + string $errorMessage = '' + ): AckMessageResultEntry { + $entry = new AckMessageResultEntry(); + $entry->setReceiptHandle($receiptHandle); + + if ($code !== Code::OK) { + $status = self::errorStatus($code, $errorMessage ?: 'Ack failed'); + $entry->setStatus($status); + } + + return $entry; + } + + /** + * Create ChangeInvisibleDurationResponse Mock object (success) + * + * @param string $receiptHandle newReceipt handle + * @param string $message Success message + * @return ChangeInvisibleDurationResponse + */ + public static function mockChangeInvisibleDurationSuccess(string $receiptHandle = '', string $message = 'OK'): ChangeInvisibleDurationResponse + { + $response = new ChangeInvisibleDurationResponse(); + $response->setStatus(self::successStatus($message)); + + if (!empty($receiptHandle)) { + $response->setReceiptHandle($receiptHandle); + } + + return $response; + } + + /** + * Create ChangeInvisibleDurationResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return ChangeInvisibleDurationResponse + */ + public static function mockChangeInvisibleDurationError(int $code = Code::INVALID_RECEIPT_HANDLE, string $message = 'Failed to change invisible duration'): ChangeInvisibleDurationResponse + { + $response = new ChangeInvisibleDurationResponse(); + $response->setStatus(self::errorStatus($code, $message)); + + return $response; + } + + /** + * Create HeartbeatResponse Mock object (success) + * + * @param string $message Success message + * @return HeartbeatResponse + */ + public static function mockHeartbeatSuccess(string $message = 'OK'): HeartbeatResponse + { + $response = new HeartbeatResponse(); + $response->setStatus(self::successStatus($message)); + + return $response; + } + + /** + * Create HeartbeatResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return HeartbeatResponse + */ + public static function mockHeartbeatError(int $code = Code::UNRECOGNIZED_CLIENT_TYPE, string $message = 'Heartbeat failed'): HeartbeatResponse + { + $response = new HeartbeatResponse(); + $response->setStatus(self::errorStatus($code, $message)); + + return $response; + } + + /** + * Create ForwardMessageToDeadLetterQueueResponse Mock object (success) + * + * @param string $message Success message + * @return ForwardMessageToDeadLetterQueueResponse + */ + public static function mockForwardToDlqSuccess(string $message = 'OK'): ForwardMessageToDeadLetterQueueResponse + { + $response = new ForwardMessageToDeadLetterQueueResponse(); + $response->setStatus(self::successStatus($message)); + + return $response; + } + + /** + * Create ForwardMessageToDeadLetterQueueResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return ForwardMessageToDeadLetterQueueResponse + */ + public static function mockForwardToDlqError(int $code = Code::MESSAGE_NOT_FOUND, string $message = 'Failed to forward to DLQ'): ForwardMessageToDeadLetterQueueResponse + { + $response = new ForwardMessageToDeadLetterQueueResponse(); + $response->setStatus(self::errorStatus($code, $message)); + + return $response; + } + + /** + * Batch create successful SendResultEntry entries + * + * @param int $count Number of entries to create + * @param string $messageIdPrefix Message ID prefix + * @return array + */ + public static function mockMultipleSendResultEntries(int $count = 3, string $messageIdPrefix = 'msg-'): array + { + $entries = []; + for ($i = 1; $i <= $count; $i++) { + $entries[] = self::mockSendResultEntry($messageIdPrefix . $i); + } + + return $entries; + } + + /** + * Batch create successful AckMessageResultEntry entries + * + * @param int $count Number of entries to create + * @param string $receiptHandlePrefix Receipt handleprefix + * @return array + */ + public static function mockMultipleAckResultEntries(int $count = 3, string $receiptHandlePrefix = 'receipt-'): array + { + $entries = []; + for ($i = 1; $i <= $count; $i++) { + $entries[] = self::mockAckMessageResultEntry($receiptHandlePrefix . $i); + } + + return $entries; + } + + /** + * Create QueryRouteResponse Mock object (success) + * + * @param array $messageQueues Array of MessageQueue objects + * @param string $message Success message + * @return QueryRouteResponse + */ + public static function mockQueryRouteSuccess(array $messageQueues = [], string $message = 'OK'): QueryRouteResponse + { + $response = new QueryRouteResponse(); + $response->setStatus(self::successStatus($message)); + + if (!empty($messageQueues)) { + $response->setMessageQueues($messageQueues); + } + + return $response; + } + + /** + * Create QueryRouteResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return QueryRouteResponse + */ + public static function mockQueryRouteError(int $code = Code::NOT_FOUND, string $message = 'Topic not found'): QueryRouteResponse + { + $response = new QueryRouteResponse(); + $response->setStatus(self::errorStatus($code, $message)); + + return $response; + } + + /** + * Create a single MessageQueue object + * + * @param string $topicName Topic name + * @param int $queueId Queue ID + * @param string $brokerName Broker name + * @param string $brokerEndpoint Broker endpoint address + * @return MessageQueue + */ + public static function mockMessageQueue( + string $topicName, + int $queueId = 0, + string $brokerName = 'broker-0', + string $brokerEndpoint = '127.0.0.1:8080' + ): MessageQueue { + $topic = new Resource(); + $topic->setName($topicName); + + $broker = new Broker(); + $broker->setName($brokerName); + + $mq = new MessageQueue(); + $mq->setTopic($topic); + $mq->setId($queueId); + $mq->setBroker($broker); + + return $mq; + } + + /** + * Create a ReceiveMessageResponse Mock object (success, with message) + * + * NOTE: ReceiveMessageResponse uses oneof, status and message are mutually exclusive. + * The actual broker sends multiple responses: first status, then message, then delivery_timestamp. + * This mock returns a response containing the message. + * + * @param Message|null $message Protobuf Message object + * @return ReceiveMessageResponse + */ + public static function mockReceiveMessageSuccess(?Message $message = null): ReceiveMessageResponse + { + $response = new ReceiveMessageResponse(); + + if ($message !== null) { + // oneof: setting message clears status + $response->setMessage($message); + } else { + $response->setStatus(self::successStatus()); + } + + return $response; + } + + /** + * Create a ReceiveMessageResponse Mock object (no message) + * + * @return ReceiveMessageResponse + */ + public static function mockReceiveMessageEmpty(): ReceiveMessageResponse + { + $response = new ReceiveMessageResponse(); + $response->setStatus(self::successStatus()); + + return $response; + } + + /** + * Create ReceiveMessageResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return ReceiveMessageResponse + */ + public static function mockReceiveMessageError(int $code = Code::INTERNAL_ERROR, string $message = 'Receive failed'): ReceiveMessageResponse + { + $response = new ReceiveMessageResponse(); + $response->setStatus(self::errorStatus($code, $message)); + + return $response; + } + + /** + * Create a Protobuf Message object + * + * @param string $topicName Topic name + * @param string $body Message body + * @param string $messageId Message ID + * @return Message + */ + public static function mockProtobufMessage(string $topicName, string $body, string $messageId = ''): Message + { + $topic = new Resource(); + $topic->setName($topicName); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody($body); + + return $message; + } + + /** + * Create EndTransactionResponse Mock object (success) + * + * @param string $message Success message + * @return EndTransactionResponse + */ + public static function mockEndTransactionSuccess(string $message = 'OK'): EndTransactionResponse + { + $response = new EndTransactionResponse(); + $response->setStatus(self::successStatus($message)); + + return $response; + } + + /** + * Create EndTransactionResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return EndTransactionResponse + */ + public static function mockEndTransactionError(int $code = Code::INVALID_TRANSACTION_ID, string $message = 'Transaction failed'): EndTransactionResponse + { + $response = new EndTransactionResponse(); + $response->setStatus(self::errorStatus($code, $message)); + + return $response; + } + + /** + * Create QueryAssignmentResponse Mock object (success) + * + * @param array $assignments Array of Assignment objects + * @param string $message Success message + * @return QueryAssignmentResponse + */ + public static function mockQueryAssignmentSuccess(array $assignments = [], string $message = 'OK'): QueryAssignmentResponse + { + $response = new QueryAssignmentResponse(); + $response->setStatus(self::successStatus($message)); + + if (!empty($assignments)) { + $response->setAssignments($assignments); + } + + return $response; + } + + /** + * Create QueryAssignmentResponse Mock object (failure) + * + * @param int $code Error code + * @param string $message Error message + * @return QueryAssignmentResponse + */ + public static function mockQueryAssignmentError(int $code = Code::NOT_FOUND, string $message = 'Assignment not found'): QueryAssignmentResponse + { + $response = new QueryAssignmentResponse(); + $response->setStatus(self::errorStatus($code, $message)); + + return $response; + } + + /** + * Create a single Assignment object + * + * @param string $topicName Topic name + * @param int $queueId Queue ID + * @param string $brokerName Broker name + * @return Assignment + */ + public static function mockAssignment(string $topicName, int $queueId = 0, string $brokerName = 'broker-0'): Assignment + { + $mq = self::mockMessageQueue($topicName, $queueId, $brokerName); + + $assignment = new Assignment(); + $assignment->setMessageQueue($mq); + + return $assignment; + } +} diff --git a/php/tests/Mocks/GrpcMocksTest.php b/php/tests/Mocks/GrpcMocksTest.php new file mode 100644 index 000000000..caeed6a04 --- /dev/null +++ b/php/tests/Mocks/GrpcMocksTest.php @@ -0,0 +1,529 @@ +assertEquals(Code::OK, $status->getCode()); + $this->assertEquals('OK', $status->getMessage()); + } + + /** + * Test creating successful Status with custom message + */ + public function testSuccessStatusWithCustomMessage(): void + { + $status = GrpcMocks::successStatus('Operation successful'); + + $this->assertEquals(Code::OK, $status->getCode()); + $this->assertEquals('Operation successful', $status->getMessage()); + } + + /** + * Test creating a failed Status object + */ + public function testErrorStatus(): void + { + $status = GrpcMocks::errorStatus(Code::INTERNAL_ERROR, 'Server error'); + + $this->assertEquals(Code::INTERNAL_ERROR, $status->getCode()); + $this->assertEquals('Server error', $status->getMessage()); + } + + /** + * Test creating a successful SendMessageResponse + */ + public function testMockSendMessageSuccess(): void + { + $response = GrpcMocks::mockSendMessageSuccess(); + + $this->assertTrue($response->hasStatus()); + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $this->assertEmpty($response->getEntries()); + } + + /** + * Test creating successful with entries SendMessageResponse + */ + public function testMockSendMessageSuccessWithEntries(): void + { + $entries = [ + GrpcMocks::mockSendResultEntry('msg-001'), + GrpcMocks::mockSendResultEntry('msg-002', 'txn-001'), + ]; + + $response = GrpcMocks::mockSendMessageSuccess($entries); + + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $this->assertCount(2, $response->getEntries()); + $this->assertEquals('msg-001', $response->getEntries()[0]->getMessageId()); + } + + /** + * Test creating a failed SendMessageResponse + */ + public function testMockSendMessageError(): void + { + $response = GrpcMocks::mockSendMessageError( + Code::MESSAGE_BODY_TOO_LARGE, + 'Message too large' + ); + + $this->assertEquals(Code::MESSAGE_BODY_TOO_LARGE, $response->getStatus()->getCode()); + $this->assertEquals('Message too large', $response->getStatus()->getMessage()); + $this->assertEmpty($response->getEntries()); + } + + /** + * Test creating SendResultEntry + */ + public function testMockSendResultEntry(): void + { + $entry = GrpcMocks::mockSendResultEntry('msg-123', 'txn-456'); + + $this->assertEquals('msg-123', $entry->getMessageId()); + $this->assertEquals('txn-456', $entry->getTransactionId()); + } + + /** + * Test creating with error SendResultEntry + */ + public function testMockSendResultEntryWithError(): void + { + $entry = GrpcMocks::mockSendResultEntry( + 'msg-failed', + '', + Code::INTERNAL_ERROR, + 'Send failed' + ); + + $this->assertEquals('msg-failed', $entry->getMessageId()); + $this->assertTrue($entry->hasStatus()); + $this->assertEquals(Code::INTERNAL_ERROR, $entry->getStatus()->getCode()); + $this->assertEquals('Send failed', $entry->getStatus()->getMessage()); + } + + /** + * Test creating a successful AckMessageResponse + */ + public function testMockAckMessageSuccess(): void + { + $response = GrpcMocks::mockAckMessageSuccess(); + + $this->assertTrue($response->hasStatus()); + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + } + + /** + * Test creating successful with entries AckMessageResponse + */ + public function testMockAckMessageSuccessWithEntries(): void + { + $entries = [ + GrpcMocks::mockAckMessageResultEntry('receipt-001'), + GrpcMocks::mockAckMessageResultEntry('receipt-002'), + ]; + + $response = GrpcMocks::mockAckMessageSuccess($entries); + + $this->assertCount(2, $response->getEntries()); + $this->assertEquals('receipt-001', $response->getEntries()[0]->getReceiptHandle()); + } + + /** + * Test creating a failed AckMessageResponse + */ + public function testMockAckMessageError(): void + { + $response = GrpcMocks::mockAckMessageError( + Code::INVALID_RECEIPT_HANDLE, + 'Invalid receipt' + ); + + $this->assertEquals(Code::INVALID_RECEIPT_HANDLE, $response->getStatus()->getCode()); + } + + /** + * Test creating AckMessageResultEntry + */ + public function testMockAckMessageResultEntry(): void + { + $entry = GrpcMocks::mockAckMessageResultEntry('receipt-handle-xyz'); + + $this->assertEquals('receipt-handle-xyz', $entry->getReceiptHandle()); + } + + /** + * Test creating with error AckMessageResultEntry + */ + public function testMockAckMessageResultEntryWithError(): void + { + $entry = GrpcMocks::mockAckMessageResultEntry( + 'receipt-handle', + Code::INVALID_RECEIPT_HANDLE, + 'Receipt expired' + ); + + $this->assertTrue($entry->hasStatus()); + $this->assertEquals(Code::INVALID_RECEIPT_HANDLE, $entry->getStatus()->getCode()); + $this->assertEquals('Receipt expired', $entry->getStatus()->getMessage()); + } + + /** + * Test creating a successful ChangeInvisibleDurationResponse + */ + public function testMockChangeInvisibleDurationSuccess(): void + { + $response = GrpcMocks::mockChangeInvisibleDurationSuccess('new-receipt-handle'); + + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $this->assertEquals('new-receipt-handle', $response->getReceiptHandle()); + } + + /** + * Test creating a failed ChangeInvisibleDurationResponse + */ + public function testMockChangeInvisibleDurationError(): void + { + $response = GrpcMocks::mockChangeInvisibleDurationError( + Code::INVALID_RECEIPT_HANDLE, + 'Cannot change duration' + ); + + $this->assertEquals(Code::INVALID_RECEIPT_HANDLE, $response->getStatus()->getCode()); + } + + /** + * Test creating a successful HeartbeatResponse + */ + public function testMockHeartbeatSuccess(): void + { + $response = GrpcMocks::mockHeartbeatSuccess(); + + $this->assertTrue($response->hasStatus()); + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + } + + /** + * Test creating a failed HeartbeatResponse + */ + public function testMockHeartbeatError(): void + { + $response = GrpcMocks::mockHeartbeatError( + Code::UNRECOGNIZED_CLIENT_TYPE, + 'Unknown client' + ); + + $this->assertEquals(Code::UNRECOGNIZED_CLIENT_TYPE, $response->getStatus()->getCode()); + } + + /** + * Test creating a successful ForwardMessageToDeadLetterQueueResponse + */ + public function testMockForwardToDlqSuccess(): void + { + $response = GrpcMocks::mockForwardToDlqSuccess(); + + $this->assertTrue($response->hasStatus()); + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + } + + /** + * Test creating a failed ForwardMessageToDeadLetterQueueResponse + */ + public function testMockForwardToDlqError(): void + { + $response = GrpcMocks::mockForwardToDlqError( + Code::MESSAGE_NOT_FOUND, + 'Message not found' + ); + + $this->assertEquals(Code::MESSAGE_NOT_FOUND, $response->getStatus()->getCode()); + } + + /** + * Test batch creating SendResultEntry + */ + public function testMockMultipleSendResultEntries(): void + { + $entries = GrpcMocks::mockMultipleSendResultEntries(5, 'batch-'); + + $this->assertCount(5, $entries); + $this->assertEquals('batch-1', $entries[0]->getMessageId()); + $this->assertEquals('batch-5', $entries[4]->getMessageId()); + } + + /** + * Test batch creating AckMessageResultEntry + */ + public function testMockMultipleAckResultEntries(): void + { + $entries = GrpcMocks::mockMultipleAckResultEntries(3, 'rcpt-'); + + $this->assertCount(3, $entries); + $this->assertEquals('rcpt-1', $entries[0]->getReceiptHandle()); + $this->assertEquals('rcpt-3', $entries[2]->getReceiptHandle()); + } + + /** + * Test defaultError code + */ + public function testDefaultErrorCodes(): void + { + $sendResponse = GrpcMocks::mockSendMessageError(); + $this->assertEquals(Code::INTERNAL_ERROR, $sendResponse->getStatus()->getCode()); + + $ackResponse = GrpcMocks::mockAckMessageError(); + $this->assertEquals(Code::INVALID_RECEIPT_HANDLE, $ackResponse->getStatus()->getCode()); + + $heartbeatResponse = GrpcMocks::mockHeartbeatError(); + $this->assertEquals(Code::UNRECOGNIZED_CLIENT_TYPE, $heartbeatResponse->getStatus()->getCode()); + + $dlqResponse = GrpcMocks::mockForwardToDlqError(); + $this->assertEquals(Code::MESSAGE_NOT_FOUND, $dlqResponse->getStatus()->getCode()); + } + + // ==================== QueryRouteResponse Tests ==================== + + /** + * Test creating a successful QueryRouteResponse + */ + public function testMockQueryRouteSuccess(): void + { + $response = GrpcMocks::mockQueryRouteSuccess(); + + $this->assertTrue($response->hasStatus()); + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $this->assertEmpty(iterator_to_array($response->getMessageQueues())); + } + + /** + * Test creating a successful QueryRouteResponse with MessageQueue + */ + public function testMockQueryRouteSuccessWithQueues(): void + { + $queues = [ + GrpcMocks::mockMessageQueue('test-topic', 0, 'broker-0', '127.0.0.1:8080'), + GrpcMocks::mockMessageQueue('test-topic', 1, 'broker-1', '127.0.0.1:8081'), + ]; + + $response = GrpcMocks::mockQueryRouteSuccess($queues); + + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $mqList = iterator_to_array($response->getMessageQueues()); + $this->assertCount(2, $mqList); + /** @var \Apache\Rocketmq\V2\MessageQueue $mq0 */ + $mq0 = $mqList[0]; + /** @var \Apache\Rocketmq\V2\MessageQueue $mq1 */ + $mq1 = $mqList[1]; + $this->assertEquals(0, $mq0->getId()); + $this->assertEquals(1, $mq1->getId()); + } + + /** + * Test creating a failed QueryRouteResponse + */ + public function testMockQueryRouteError(): void + { + $response = GrpcMocks::mockQueryRouteError(Code::NOT_FOUND, 'Topic not found'); + + $this->assertEquals(Code::NOT_FOUND, $response->getStatus()->getCode()); + $this->assertEquals('Topic not found', $response->getStatus()->getMessage()); + } + + /** + * Test creating a MessageQueue object + */ + public function testMockMessageQueue(): void + { + $mq = GrpcMocks::mockMessageQueue('my-topic', 3, 'broker-x', '10.0.0.1:8080'); + + $this->assertTrue($mq->hasTopic()); + $this->assertEquals('my-topic', $mq->getTopic()->getName()); + $this->assertEquals(3, $mq->getId()); + $this->assertTrue($mq->hasBroker()); + $this->assertEquals('broker-x', $mq->getBroker()->getName()); + } + + // ==================== ReceiveMessageResponse Tests ==================== + + /** + * Test creating a successful ReceiveMessageResponse (with message) + * NOTE: oneof field, setting message clears status + */ + public function testMockReceiveMessageSuccessWithMessage(): void + { + $msg = GrpcMocks::mockProtobufMessage('test-topic', 'hello world'); + $response = GrpcMocks::mockReceiveMessageSuccess($msg); + + // oneof: hasStatus=false when hasMessage=true + $this->assertTrue($response->hasMessage()); + $this->assertFalse($response->hasStatus()); + $this->assertEquals('hello world', $response->getMessage()->getBody()); + } + + /** + * Test creating a successful ReceiveMessageResponse (no message, only status) + */ + public function testMockReceiveMessageEmpty(): void + { + $response = GrpcMocks::mockReceiveMessageEmpty(); + + $this->assertTrue($response->hasStatus()); + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $this->assertFalse($response->hasMessage()); + } + + /** + * Test creating a failed ReceiveMessageResponse + */ + public function testMockReceiveMessageError(): void + { + $response = GrpcMocks::mockReceiveMessageError(Code::INTERNAL_ERROR, 'Broker unavailable'); + + $this->assertEquals(Code::INTERNAL_ERROR, $response->getStatus()->getCode()); + $this->assertEquals('Broker unavailable', $response->getStatus()->getMessage()); + } + + /** + * Test creating Protobuf Message object + */ + public function testMockProtobufMessage(): void + { + $msg = GrpcMocks::mockProtobufMessage('order-topic', 'order data'); + + $this->assertTrue($msg->hasTopic()); + $this->assertEquals('order-topic', $msg->getTopic()->getName()); + $this->assertEquals('order data', $msg->getBody()); + } + + // ==================== EndTransactionResponse Tests ==================== + + /** + * Test creating a successful EndTransactionResponse + */ + public function testMockEndTransactionSuccess(): void + { + $response = GrpcMocks::mockEndTransactionSuccess(); + + $this->assertTrue($response->hasStatus()); + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $this->assertEquals('OK', $response->getStatus()->getMessage()); + } + + /** + * Test creating a failed EndTransactionResponse + */ + public function testMockEndTransactionError(): void + { + $response = GrpcMocks::mockEndTransactionError(Code::INVALID_TRANSACTION_ID, 'Invalid transaction'); + + $this->assertEquals(Code::INVALID_TRANSACTION_ID, $response->getStatus()->getCode()); + $this->assertEquals('Invalid transaction', $response->getStatus()->getMessage()); + } + + /** + * Test default EndTransaction Error code + */ + public function testMockEndTransactionDefaultErrorCode(): void + { + $response = GrpcMocks::mockEndTransactionError(); + + $this->assertEquals(Code::INVALID_TRANSACTION_ID, $response->getStatus()->getCode()); + } + + // ==================== QueryAssignmentResponse Tests ==================== + + /** + * Test creating a successful QueryAssignmentResponse + */ + public function testMockQueryAssignmentSuccess(): void + { + $response = GrpcMocks::mockQueryAssignmentSuccess(); + + $this->assertTrue($response->hasStatus()); + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $this->assertEmpty(iterator_to_array($response->getAssignments())); + } + + /** + * Test creating successful QueryAssignmentResponse with Assignments + */ + public function testMockQueryAssignmentSuccessWithAssignments(): void + { + $assignments = [ + GrpcMocks::mockAssignment('test-topic', 0, 'broker-0'), + GrpcMocks::mockAssignment('test-topic', 1, 'broker-1'), + ]; + + $response = GrpcMocks::mockQueryAssignmentSuccess($assignments); + + $this->assertEquals(Code::OK, $response->getStatus()->getCode()); + $list = iterator_to_array($response->getAssignments()); + $this->assertCount(2, $list); + /** @var \Apache\Rocketmq\V2\Assignment $a0 */ + $a0 = $list[0]; + /** @var \Apache\Rocketmq\V2\Assignment $a1 */ + $a1 = $list[1]; + $this->assertTrue($a0->hasMessageQueue()); + $this->assertEquals(0, $a0->getMessageQueue()->getId()); + $this->assertEquals(1, $a1->getMessageQueue()->getId()); + } + + /** + * Test creating a failed QueryAssignmentResponse + */ + public function testMockQueryAssignmentError(): void + { + $response = GrpcMocks::mockQueryAssignmentError(Code::NOT_FOUND, 'No assignment'); + + $this->assertEquals(Code::NOT_FOUND, $response->getStatus()->getCode()); + $this->assertEquals('No assignment', $response->getStatus()->getMessage()); + } + + /** + * Test creating an Assignment object + */ + public function testMockAssignment(): void + { + $assignment = GrpcMocks::mockAssignment('my-topic', 5, 'broker-y'); + + $this->assertTrue($assignment->hasMessageQueue()); + $mq = $assignment->getMessageQueue(); + $this->assertEquals('my-topic', $mq->getTopic()->getName()); + $this->assertEquals(5, $mq->getId()); + $this->assertEquals('broker-y', $mq->getBroker()->getName()); + } +} diff --git a/php/tests/ProcessQueueConsumerTest.php b/php/tests/ProcessQueueConsumerTest.php new file mode 100644 index 000000000..6cc6a912e --- /dev/null +++ b/php/tests/ProcessQueueConsumerTest.php @@ -0,0 +1,318 @@ +setHost('127.0.0.1'); + $address->setPort(8080); + + $endpoints = new Endpoints(); + $endpoints->setScheme(AddressScheme::IPv4); + $endpoints->setAddresses([$address]); + + $broker = new Broker(); + $broker->setName('test-broker'); + $broker->setEndpoints($endpoints); + + $topic = new Resource(); + $topic->setName('test-topic'); + + $queue = new MessageQueue(); + $queue->setTopic($topic); + $queue->setBroker($broker); + $queue->setId(0); + $queue->setPermission(Permission::READ_WRITE); + + return $queue; + } + + private function createMessage(string $body, string $receiptHandle = null): Message + { + $sysProps = new SystemProperties(); + if ($receiptHandle !== null) { + $sysProps->setReceiptHandle($receiptHandle); + } + + $msg = new Message(); + $msg->setBody($body); + $msg->setSystemProperties($sysProps); + return $msg; + } + + public function setUp(): void + { + \Apache\Rocketmq\Logger::close(); + } + + // ----------------------------------------------------------------------- + // ConsumerInterface interaction tests + // ----------------------------------------------------------------------- + + public function testConstructorWithConsumerInterface() + { + $consumer = new \FakeConsumer('consumer-001'); + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, 'tagA'); + + $this->assertFalse($pq->isDropped()); + $this->assertEquals(0, $pq->cachedMessagesCount()); + $this->assertEquals(0, $pq->cachedMessageBytes()); + $this->assertSame($mq, $pq->getMessageQueue()); + } + + public function testGetMessageQueueFromConsumer() + { + $consumer = new \FakeConsumer('consumer-002'); + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + $returned = $pq->getMessageQueue(); + + $this->assertEquals('test-topic', $returned->getTopic()->getName()); + $this->assertEquals('test-broker', $returned->getBroker()->getName()); + } + + public function testDropStopsFetchingForConsumer() + { + $consumer = new \FakeConsumer('consumer-003'); + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + $this->assertFalse($pq->isDropped()); + + $pq->drop(); + $this->assertTrue($pq->isDropped()); + + $count = $pq->fetchMessages(); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------------- + // Ack / Nack interaction via eraseMessage + // ----------------------------------------------------------------------- + + public function testEraseMessageSuccessCallsAckOnConsumer() + { + $consumer = new \FakeConsumer('consumer-ack-001'); + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + + $msg = $this->createMessage('hello', 'rh-ack-001'); + $pq->testCacheMessages([$msg]); + + $messageViews = $pq->getCachedMessages(); + $this->assertCount(1, $messageViews); + + $pq->eraseMessage($messageViews[0], \Apache\Rocketmq\ConsumeResult::SUCCESS); + + $this->assertCount(1, $consumer->ackCalls, 'ackMessage should be called once'); + $this->assertCount(0, $consumer->nackCalls, 'nackMessage should not be called'); + $this->assertEquals(0, $pq->cachedMessagesCount(), 'Message should be evicted'); + } + + public function testEraseMessageFailureCallsNackOnConsumer() + { + $consumer = new \FakeConsumer('consumer-nack-001'); + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + + $msg = $this->createMessage('world', 'rh-nack-001'); + $pq->testCacheMessages([$msg]); + + $messageViews = $pq->getCachedMessages(); + $this->assertCount(1, $messageViews); + + $pq->eraseMessage($messageViews[0], \Apache\Rocketmq\ConsumeResult::FAILURE); + + $this->assertCount(0, $consumer->ackCalls, 'ackMessage should not be called'); + $this->assertCount(1, $consumer->nackCalls, 'nackMessage should be called once'); + $this->assertEquals(0, $pq->cachedMessagesCount()); + } + + public function testDiscardMessageCallsNackAndEvicts() + { + $consumer = new \FakeConsumer('consumer-discard-001'); + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + + $msg = $this->createMessage('discard-me', 'rh-discard-001'); + $pq->testCacheMessages([$msg]); + + $messageViews = $pq->getCachedMessages(); + $this->assertCount(1, $messageViews); + + $pq->discardMessage($messageViews[0]); + + $this->assertCount(1, $consumer->nackCalls, 'discardMessage should call nackMessage'); + $this->assertEquals(0, $pq->cachedMessagesCount(), 'Message should be evicted'); + } + + // ----------------------------------------------------------------------- + // Cache threshold tests with consumer configuration + // ----------------------------------------------------------------------- + + public function testCacheFullRespectsConsumerCountThreshold() + { + $consumer = new \FakeConsumer('consumer-threshold-001'); + $consumer->countThreshold = 2; + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + + $this->assertFalse($pq->isCacheFull()); + + $pq->testCacheMessages([$this->createMessage('a', 'rh-a')]); + $this->assertFalse($pq->isCacheFull()); + + $pq->testCacheMessages([$this->createMessage('b', 'rh-b')]); + $this->assertTrue($pq->isCacheFull()); + $this->assertEquals(2, $pq->cachedMessagesCount()); + } + + public function testCacheFullRespectsConsumerBytesThreshold() + { + $consumer = new \FakeConsumer('consumer-bytes-001'); + $consumer->countThreshold = 1024; // high + $consumer->bytesThreshold = 10; // low byte limit + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + + // "12345678901" = 11 bytes > threshold of 10 + $pq->testCacheMessages([$this->createMessage('12345678901', 'rh-bytes')]); + $this->assertTrue($pq->isCacheFull()); + } + + // ----------------------------------------------------------------------- + // Eviction with consumer configuration + // ----------------------------------------------------------------------- + + public function testEvictMessageWithConsumerBytesTracking() + { + $consumer = new \FakeConsumer('consumer-evict-001'); + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + + $msg1 = $this->createMessage('AAAA', 'rh-evict-1'); + $msg2 = $this->createMessage('BB', 'rh-evict-2'); + $pq->testCacheMessages([$msg1, $msg2]); + + $this->assertEquals(6, $pq->cachedMessageBytes()); + $this->assertEquals(2, $pq->cachedMessagesCount()); + + $cached = $pq->getCachedMessages(); + $pq->evictMessage($cached[0]); + + $this->assertEquals(2, $pq->cachedMessageBytes()); + $this->assertEquals(1, $pq->cachedMessagesCount()); + } + + // ----------------------------------------------------------------------- + // Multiple erase operations + // ----------------------------------------------------------------------- + + public function testMultipleEraseOperationsTrackConsumerCalls() + { + $consumer = new \FakeConsumer('consumer-multi-001'); + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + + $messages = [ + $this->createMessage('msg-1', 'rh-multi-1'), + $this->createMessage('msg-2', 'rh-multi-2'), + $this->createMessage('msg-3', 'rh-multi-3'), + ]; + $pq->testCacheMessages($messages); + + $cached = $pq->getCachedMessages(); + $this->assertCount(3, $cached); + + $pq->eraseMessage($cached[0], \Apache\Rocketmq\ConsumeResult::SUCCESS); + $pq->eraseMessage($cached[1], \Apache\Rocketmq\ConsumeResult::FAILURE); + $pq->eraseMessage($cached[2], \Apache\Rocketmq\ConsumeResult::SUCCESS); + + $this->assertCount(2, $consumer->ackCalls, 'Two SUCCESS = two ack calls'); + $this->assertCount(1, $consumer->nackCalls, 'One FAILURE = one nack call'); + $this->assertEquals(0, $pq->cachedMessagesCount()); + } + + // ----------------------------------------------------------------------- + // Expired tests with consumer awaitDuration + // ----------------------------------------------------------------------- + + public function testExpiredWithConsumerAwaitDuration() + { + $consumer = new \FakeConsumer('consumer-expire-001'); + $consumer->awaitDuration = 1; // 1 second + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + $this->assertFalse($pq->expired()); + } + + // ----------------------------------------------------------------------- + // fetchMessageImmediately with full cache + // ----------------------------------------------------------------------- + + public function testFetchMessageImmediatelyWhenCacheFull() + { + $consumer = new \FakeConsumer('consumer-fetch-001'); + $consumer->countThreshold = 1; + $mq = $this->createMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($consumer, $mq, '*'); + + $pq->testCacheMessages([$this->createMessage('full', 'rh-full')]); + $this->assertTrue($pq->isCacheFull()); + + // fetchMessageImmediately just sets a flag, should not throw + $pq->fetchMessageImmediately(); + $this->assertTrue(true); + } +} diff --git a/php/tests/ProcessQueueTest.php b/php/tests/ProcessQueueTest.php new file mode 100644 index 000000000..bc524bdd9 --- /dev/null +++ b/php/tests/ProcessQueueTest.php @@ -0,0 +1,282 @@ +setHost('127.0.0.1'); + $address->setPort(8080); + + $endpoints = new Endpoints(); + $endpoints->setScheme(AddressScheme::IPv4); + $endpoints->setAddresses([$address]); + + $broker = new Broker(); + $broker->setName('test-broker'); + $broker->setEndpoints($endpoints); + + $topic = new Resource(); + $topic->setName('test-topic'); + + $queue = new MessageQueue(); + $queue->setTopic($topic); + $queue->setBroker($broker); + $queue->setId(0); + $queue->setPermission(Permission::READ_WRITE); + + return $queue; + } + + public function setUp(): void + { + \Apache\Rocketmq\Logger::close(); + } + + public function testConstructorAndInitialState() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $this->assertFalse($pq->isDropped(), "ProcessQueue should not be dropped initially"); + $this->assertEquals(0, $pq->cachedMessagesCount(), "Cached messages count should be 0"); + $this->assertEquals(0, $pq->cachedMessageBytes(), "Cached message bytes should be 0"); + } + + public function testDropAndIsDropped() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $this->assertFalse($pq->isDropped(), "Should not be dropped before drop()"); + $pq->drop(); + $this->assertTrue($pq->isDropped(), "Should be dropped after drop()"); + } + + public function testGetMessageQueue() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $resultMq = $pq->getMessageQueue(); + $this->assertTrue( + $resultMq->getTopic()->getName() === 'test-topic', + "getMessageQueue should return the original MessageQueue" + ); + } + + public function testFetchMessageImmediately() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + $pq->fetchMessageImmediately(); + + $this->assertTrue(true, "fetchMessageImmediately should not throw"); + } + + public function testExpiredNotInitially() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $this->assertFalse($pq->expired(), "ProcessQueue should not be expired immediately after creation"); + } + + public function testCacheNotFullInitially() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $this->assertFalse($pq->isCacheFull(), "Cache should not be full with no messages"); + } + + /** + * Helper to build a V2\Message protobuf object for testing. + */ + private function createFakeMessage(string $body, string $receiptHandle = null): Message + { + $sysProps = new SystemProperties(); + if ($receiptHandle !== null) { + $sysProps->setReceiptHandle($receiptHandle); + } + + $msg = new Message(); + $msg->setBody($body); + $msg->setSystemProperties($sysProps); + return $msg; + } + + /** + * Tests that cacheMessages and eviction track count/bytes correctly. + */ + public function testCachedMessagesCountAndBytes() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $msg = $this->createFakeMessage("hello world", "rh-001"); + $pq->testCacheMessages([$msg]); + + $this->assertEquals(1, $pq->cachedMessagesCount(), "Cached count should be 1"); + $this->assertEquals(11, $pq->cachedMessageBytes(), "Cached bytes should be 11"); + } + + /** + * eraseMessage with SUCCESS should call ackMessage and evict. + */ + public function testEraseMessageWithConsumeSuccess() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $msg = $this->createFakeMessage("test-message", "rh-002"); + $pq->testCacheMessages([$msg]); + + $this->assertEquals(1, $pq->cachedMessagesCount(), "Should have 1 cached message"); + + $messageViews = $pq->getCachedMessages(); + $pq->eraseMessage($messageViews[0], \Apache\Rocketmq\ConsumeResult::SUCCESS); + + $this->assertEquals(0, $pq->cachedMessagesCount(), "Message should be evicted after erase"); + $this->assertCount(1, $fakeConsumer->ackCalls, "ackMessage should be called for SUCCESS"); + $this->assertCount(0, $fakeConsumer->nackCalls, "nackMessage should NOT be called for SUCCESS"); + } + + /** + * eraseMessage with FAILURE should call nackMessage and evict. + */ + public function testEraseMessageWithConsumeFailure() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $msg = $this->createFakeMessage("test-message", "rh-003"); + $pq->testCacheMessages([$msg]); + + $messageViews = $pq->getCachedMessages(); + $pq->eraseMessage($messageViews[0], \Apache\Rocketmq\ConsumeResult::FAILURE); + + $this->assertEquals(0, $pq->cachedMessagesCount(), "Message should be evicted after erase"); + $this->assertCount(0, $fakeConsumer->ackCalls, "ackMessage should NOT be called for FAILURE"); + $this->assertCount(1, $fakeConsumer->nackCalls, "nackMessage should be called for FAILURE"); + } + + /** + * Tests that cache full blocks further caching. + */ + public function testCacheFullThreshold() + { + $fakeConsumer = new \FakeConsumer(); + $fakeConsumer->countThreshold = 3; + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + // Fill up to threshold + for ($i = 0; $i < 3; $i++) { + $msg = $this->createFakeMessage("msg-{$i}", "rh-threshold-{$i}"); + $pq->testCacheMessages([$msg]); + } + + $this->assertTrue($pq->isCacheFull(), "Cache should be full at threshold"); + + // Verify messages still count + $this->assertEquals(3, $pq->cachedMessagesCount(), "Should have 3 cached messages"); + } + + /** + * Tests that dropped ProcessQueue returns 0 from fetchMessages. + */ + public function testDroppedQueueDoesNotFetch() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + $pq->drop(); + + // After drop, fetchMessages should return 0 immediately without calling broker + $count = $pq->fetchMessages(); + $this->assertEquals(0, $count, "Dropped queue should fetch 0 messages"); + } + + /** + * Tests evictMessage reduces byte count correctly. + */ + public function testEvictMessageReducesBytes() + { + $fakeConsumer = new \FakeConsumer(); + $mq = $this->createFakeMessageQueue(); + + $pq = new \Apache\Rocketmq\ProcessQueue($fakeConsumer, $mq, '*'); + + $msg1 = $this->createFakeMessage("hello", "rh-010"); + $msg2 = $this->createFakeMessage("world!", "rh-011"); + $pq->testCacheMessages([$msg1, $msg2]); + + $initialBytes = $pq->cachedMessageBytes(); + $this->assertEquals(11, $initialBytes, "Initial bytes should be 11 (5+6)"); + + $messageViews = $pq->getCachedMessages(); + $pq->evictMessage($messageViews[0]); + + $afterBytes = $pq->cachedMessageBytes(); + $this->assertEquals(6, $afterBytes, "Bytes should be 6 after evicting 5-byte message"); + $this->assertEquals(1, $pq->cachedMessagesCount(), "Count should be 1 after eviction"); + } +} diff --git a/php/tests/ProducerValidationTest.php b/php/tests/ProducerValidationTest.php new file mode 100644 index 000000000..74b5bd39b --- /dev/null +++ b/php/tests/ProducerValidationTest.php @@ -0,0 +1,351 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/endpoints/i'); + new Producer('', []); + } + + /** + * Mirrors Java: testSetTopicWithNull + * PHP: Message with null topic should fail validation. + */ + public function testSendMessageWithNullTopic() + { + // Use reflection to simulate running state since we can't start gRPC + $producer = new Producer('127.0.0.1:9876'); + $ref = new \ReflectionProperty($producer, 'isRunning'); + $ref->setAccessible(true); + $ref->setValue($producer, true); + + $message = new Message(); + $this->expectException(\InvalidArgumentException::class); + $producer->send($message); + } + + /** + * Mirrors Java: testSetIllegalTopic + * PHP: Message with whitespace-only topic should fail validation. + */ + public function testSendMessageWithIllegalTopic() + { + $producer = new Producer('127.0.0.1:9876'); + $ref = new \ReflectionProperty($producer, 'isRunning'); + $ref->setAccessible(true); + $ref->setValue($producer, true); + + $message = new Message(); + $tabTopic = new Resource(); + $tabTopic->setName("\t"); + $message->setTopic($tabTopic); + $this->expectException(\InvalidArgumentException::class); + $producer->send($message); + } + + /** + * Mirrors Java: testSetTopic + * PHP: valid topic should pass validation (no exception from validation). + */ + public function testValidTopic() + { + $producer = new Producer('127.0.0.1:9876'); + $ref = new \ReflectionProperty($producer, 'isRunning'); + $ref->setAccessible(true); + $ref->setValue($producer, true); + + // Valid topic with null body should throw for body, not topic + $validTopic = new Resource(); + $validTopic->setName('abc'); + $message = new Message(); + $message->setTopic($validTopic); + // No body set - should throw for body validation + $this->expectException(\InvalidArgumentException::class); + $producer->send($message); + } + + /** + * Mirrors Java: testSetNegativeMaxAttempts + * PHP: maxAttempts is validated via ExponentialBackoffRetryPolicy. + */ + public function testNegativeMaxAttempts() + { + $this->expectException(\InvalidArgumentException::class); + new Producer('127.0.0.1:9876', [ + 'maxAttempts' => -1, + ]); + } + + /** + * Mirrors Java: testSetMaxAttempts + * PHP: maxAttempts should be set correctly. + */ + public function testSetMaxAttempts() + { + $producer = new Producer('127.0.0.1:9876', [ + 'maxAttempts' => 3, + ]); + + $ref = new \ReflectionProperty($producer, 'settings'); + $ref->setAccessible(true); + $settings = $ref->getValue($producer); + $this->assertEquals(3, $settings->getMaxAttempts(), "maxAttempts should be 3"); + } + + /** + * Mirrors Java: testSetTransactionCheckerWithNull + * PHP: transactionChecker is stored in Transaction object, not producer. + * We verify the producer has the beginTransaction method. + */ + public function testBeginTransactionWhenNotRunning() + { + // Producer not running should throw + $producer = new Producer('127.0.0.1:9876'); + + $this->expectException(\RuntimeException::class); + $producer->beginTransaction(); + } + + /** + * Mirrors Java: testSetTransactionChecker + * PHP: beginTransaction should work when producer is running. + */ + public function testBeginTransactionWhenRunning() + { + $producer = new Producer('127.0.0.1:9876'); + $ref = new \ReflectionProperty($producer, 'isRunning'); + $ref->setAccessible(true); + $ref->setValue($producer, true); + + // Must set a TransactionChecker before beginTransaction + $checker = new FakeTransactionChecker(); + $producer->setTransactionChecker($checker); + + $tx = $producer->beginTransaction(); + $this->assertNotNull($tx, "beginTransaction should return a transaction"); + } + + /** + * Mirrors Java: testBuildWithoutClientConfiguration + * PHP: empty endpoints at construction doesn't throw (gRPC defers errors). + * We verify the object is created - connection fails lazily. + */ + public function testConstructorValidatesEndpoints() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/endpoints/i'); + new Producer(''); + } + + /** + * PHP specific: send when producer is not running. + */ + public function testSendWhenNotRunning() + { + $producer = new Producer('127.0.0.1:9876'); + + $message = new Message(); + $topic = new Resource(); + $topic->setName('test-topic'); + $message->setTopic($topic); + $message->setBody('hello'); + $this->expectException(\RuntimeException::class); + $producer->send($message); + } + + /** + * Mirrors Java: testRecall - recallMessage when not running should throw. + */ + public function testRecallWhenNotRunning() + { + $producer = new Producer('127.0.0.1:9876'); + + $this->expectException(\RuntimeException::class); + $producer->recallMessage('test-topic', 'handle-123'); + } + + /** + * Mirrors Java: testSendBeforeStartup + * Producer not running, send should throw. + */ + public function testSendBeforeStartup() + { + $producer = new Producer('127.0.0.1:9876', [ + 'topics' => ['test-topic'], + ]); + + $message = new Message(); + $topic = new Resource(); + $topic->setName('test-topic'); + $message->setTopic($topic); + $message->setBody('body'); + + $this->expectException(\RuntimeException::class); + $producer->send($message); + } + + /** + * Tests that beginTransaction requires running producer. + */ + public function testBeginTransactionRequiresRunning() + { + $producer = new Producer('127.0.0.1:9876'); + + $this->expectException(\RuntimeException::class); + $producer->beginTransaction(); + } + + /** + * Tests recallMessageAsync also requires running producer. + */ + public function testRecallMessageAsyncWhenNotRunning() + { + $producer = new Producer('127.0.0.1:9876'); + + $this->expectException(\RuntimeException::class); + foreach ($producer->recallMessageAsync('test-topic', 'handle') as $result) { + // generator execution will trigger the throw + } + } + + /** + * Tests message size validation (exceeds 4MB limit). + * Mirrors Java: testSendWithOversizedMessage. + */ + public function testSendWithOversizedMessage() + { + $producer = new Producer('127.0.0.1:9876'); + $ref = new \ReflectionProperty($producer, 'isRunning'); + $ref->setAccessible(true); + $ref->setValue($producer, true); + + $message = new Message(); + $topic = new Resource(); + $topic->setName('test-topic'); + $message->setTopic($topic); + // 4MB + 1 byte + $message->setBody(str_repeat('x', 4 * 1024 * 1024 + 1)); + + $this->expectException(\InvalidArgumentException::class); + $producer->send($message); + } + + /** + * Tests sendFifoMessage when not running. + */ + public function testSendFifoMessageWhenNotRunning() + { + $producer = new Producer('127.0.0.1:9876'); + + $this->expectException(\RuntimeException::class); + $producer->sendFifoMessage('test-topic', 'body', 'group-1'); + } + + /** + * Tests sendPriorityMessage when not running. + */ + public function testSendPriorityMessageWhenNotRunning() + { + $producer = new Producer('127.0.0.1:9876'); + + $this->expectException(\RuntimeException::class); + $producer->sendPriorityMessage('test-topic', 'body', 1); + } + + /** + * Tests sendDelayedMessage when not running. + */ + public function testSendDelayedMessageWhenNotRunning() + { + $producer = new Producer('127.0.0.1:9876'); + + $this->expectException(\RuntimeException::class); + $producer->sendDelayedMessage('test-topic', 'body', time() + 3600); + } + + /** + * Tests shutdown when not running (should be a no-op). + */ + public function testShutdownWhenNotRunning() + { + $producer = new Producer('127.0.0.1:9876'); + + // Should not throw + $producer->shutdown(); + $this->assertFalse($producer->isRunning(), "Producer should not be running after shutdown"); + } + + /** + * Tests getClientId returns a non-empty value. + */ + public function testGetClientId() + { + $producer = new Producer('127.0.0.1:9876', [ + 'clientId' => 'my-test-producer', + ]); + + $this->assertEquals( + 'my-test-producer', + $producer->getClientId(), + "ClientId should match configured value" + ); + } +} + +/** + * Fake TransactionChecker for test use. + */ +class FakeTransactionChecker implements \Apache\Rocketmq\TransactionChecker +{ + public function check(\Apache\Rocketmq\MessageView $messageView): int + { + return \Apache\Rocketmq\V2\TransactionResolution::COMMIT; + } +} + diff --git a/php/tests/PublishingLoadBalancerTest.php b/php/tests/PublishingLoadBalancerTest.php new file mode 100644 index 000000000..fe3129907 --- /dev/null +++ b/php/tests/PublishingLoadBalancerTest.php @@ -0,0 +1,229 @@ +messageQueues = $messageQueues; + } + + public function getMessageQueues() + { + return $this->messageQueues; + } +} + +class PublishingLoadBalancerTest extends TestCase +{ + /** + * Build $count writable master-broker queues named broker-0..broker-{count-1}. + */ + private function buildQueues(int $count): array + { + $queues = []; + for ($i = 0; $i < $count; $i++) { + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8080 + $i); + + $endpoints = new V2Endpoints(); + $endpoints->setScheme(AddressScheme::IPv4); + $endpoints->setAddresses([$address]); + + $broker = new Broker(); + $broker->setName("broker-{$i}"); + $broker->setEndpoints($endpoints); + + $topic = new Resource(); + $topic->setName('test-topic'); + + $queue = new MessageQueue(); + $queue->setTopic($topic); + $queue->setBroker($broker); + $queue->setPermission(Permission::READ_WRITE); + + $queues[] = $queue; + } + return $queues; + } + + private function fakePbMessageQueue0() + { + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8080); + + $endpoints = new V2Endpoints(); + $endpoints->setScheme(AddressScheme::IPv4); + $endpoints->setAddresses([$address]); + + $broker = new Broker(); + $broker->setName('foo-bar-broker-0'); + $broker->setEndpoints($endpoints); + + $topic = new Resource(); + $topic->setName('foo-bar-topic-0'); + + $queue = new MessageQueue(); + $queue->setTopic($topic); + $queue->setBroker($broker); + $queue->setPermission(Permission::READ_WRITE); + $queue->setAcceptMessageTypes([V2MessageType::NORMAL]); + + return $queue; + } + + public function testTakeMessageQueueByMessageGroup() + { + $messageQueue = $this->fakePbMessageQueue0(); + $routeData = new FakeRouteData([$messageQueue]); + $loadBalancer = new PublishingLoadBalancer($routeData); + + $result = $loadBalancer->takeMessageQueueByMessageGroup('test'); + $this->assertNotNull($result, "Should return a message queue"); + } + + /** + * Cross-client FIFO queue selection: expected indices equal Java's + * LongMath.mod(Hashing.sipHash24().hashBytes(group.getBytes(UTF_8)).asLong(), queueCount) + * (and the Node.js client's siphash24-based selection), so the same message + * group must map to the same queue in every language client. + */ + public function testTakeMessageQueueByMessageGroupMatchesJavaAndNodeClients() + { + $expectedByQueueCount = [ + 3 => [ + 'message-group-0' => 1, + 'message-group-1' => 1, + 'fifo-group' => 2, + 'order-12345' => 2, + 'RocketMQ' => 0, + ], + 5 => [ + 'message-group-0' => 0, + 'message-group-1' => 1, + 'fifo-group' => 4, + 'order-12345' => 3, + 'RocketMQ' => 0, + ], + ]; + + foreach ($expectedByQueueCount as $queueCount => $expectations) { + $loadBalancer = new PublishingLoadBalancer(new FakeRouteData($this->buildQueues($queueCount))); + foreach ($expectations as $group => $expectedIndex) { + $selected = $loadBalancer->takeMessageQueueByMessageGroup($group); + $this->assertSame( + "broker-{$expectedIndex}", + $selected->getBroker()->getName(), + "Group '{$group}' with {$queueCount} queues should select queue {$expectedIndex}" + ); + // Deterministic: repeated selection must return the same queue + $again = $loadBalancer->takeMessageQueueByMessageGroup($group); + $this->assertSame($selected, $again); + } + } + } + + public function testTakeTwoMessageQueuesWithSingleQueue() + { + $messageQueue = $this->fakePbMessageQueue0(); + $routeData = new FakeRouteData([$messageQueue]); + $loadBalancer = new PublishingLoadBalancer($routeData); + + $result = $loadBalancer->takeMessageQueue([], 2); + $this->assertEquals(1, count($result), "Should return only 1 queue when only 1 exists"); + } + + public function testTakeMessageQueuesWithAllEndpointsIsolated() + { + $messageQueue = $this->fakePbMessageQueue0(); + $routeData = new FakeRouteData([$messageQueue]); + $loadBalancer = new PublishingLoadBalancer($routeData); + + $brokerName = $messageQueue->getBroker()->getName(); + + // When all endpoints are isolated, should still return queues (round two fallback) + $result = $loadBalancer->takeMessageQueue([$brokerName], 1); + $this->assertNotNull($result, "Should return queues even when all endpoints are isolated"); + $this->assertEquals(1, count($result), "Should return 1 queue"); + } + + public function testTakeMessageQueueRoundRobin() + { + $queues = []; + for ($i = 0; $i < 3; $i++) { + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8080 + $i); + + $endpoints = new V2Endpoints(); + $endpoints->setScheme(AddressScheme::IPv4); + $endpoints->setAddresses([$address]); + + $broker = new Broker(); + $broker->setName("broker-{$i}"); + $broker->setEndpoints($endpoints); + + $topic = new Resource(); + $topic->setName('test-topic'); + + $queue = new MessageQueue(); + $queue->setTopic($topic); + $queue->setBroker($broker); + $queue->setPermission(Permission::READ_WRITE); + + $queues[] = $queue; + } + + $routeData = new FakeRouteData($queues); + $loadBalancer = new PublishingLoadBalancer($routeData); + + // Take 1 queue at a time, should get different brokers over time + $brokers = []; + for ($i = 0; $i < 6; $i++) { + $result = $loadBalancer->takeMessageQueue([], 1); + if (!empty($result)) { + $brokers[] = $result[0]->getBroker()->getName(); + } + } + + $uniqueBrokers = array_unique($brokers); + $this->assertTrue(count($uniqueBrokers) >= 2, "Should distribute across multiple brokers"); + } +} + diff --git a/php/tests/PushConsumerTest.php b/php/tests/PushConsumerTest.php new file mode 100644 index 000000000..ca588502b --- /dev/null +++ b/php/tests/PushConsumerTest.php @@ -0,0 +1,319 @@ + function($msg) { return 0; }, + ]); + + $this->expectException(\RuntimeException::class); + $consumer->start(); + } + + /** + * Mirrors Java: testSetConsumerGroupWithNull + */ + public function testConstructorWithNullConsumerGroup() + { + $this->expectException(\InvalidArgumentException::class); + new PushConsumer('127.0.0.1:9876', ''); + } + + /** + * Tests that null messageListener is caught at start(). + * Mirrors Java: testSetMessageListenerWithNull + */ + public function testStartWithoutMessageListener() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->expectException(\RuntimeException::class); + $consumer->start(); + } + + /** + * Tests negative maxCacheMessageCount validation. + */ + public function testNegativeMaxCacheMessageCount() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'maxCacheMessageCount' => -1, + ]); + + $threshold = $consumer->getCacheMessageCountThresholdPerQueue(); + $this->assertTrue( + $threshold >= 0, + "Cache count threshold should be non-negative (got {$threshold})" + ); + } + + /** + * Tests negative maxCacheMessageSizeInBytes validation. + */ + public function testNegativeMaxCacheMessageSize() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'maxCacheMessageSizeInBytes' => -1, + ]); + + $threshold = $consumer->getCacheMessageBytesThresholdPerQueue(); + $this->assertTrue( + $threshold >= 0, + "Cache bytes threshold should be non-negative (got {$threshold})" + ); + } + + /** + * Tests unsubscribe before start (mirrors Java testUnsubscribeBeforeStartup). + */ + public function testUnsubscribeBeforeStart() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + 'messageListener' => function($msg) { return 0; }, + ]); + + $consumer->unsubscribe('test-topic'); + + $expressions = $consumer->getSubscriptionExpressions(); + $this->assertTrue(empty($expressions), "Topic should be removed from subscriptions"); + } + + /** + * Tests subscribe before start. + */ + public function testSubscribeBeforeStart() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'messageListener' => function($msg) { return 0; }, + ]); + + $consumer->subscribe('new-topic', 'tagA'); + + $expressions = $consumer->getSubscriptionExpressions(); + $this->assertTrue( + isset($expressions['new-topic']), + "Topic should be added to subscriptions" + ); + $this->assertEquals( + 'tagA', + $expressions['new-topic'], + "Expression should match" + ); + } + + /** + * Tests that start() rejects when consumer is already running. + */ + public function testStartWhenAlreadyRunning() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + 'messageListener' => function($msg) { return 0; }, + ]); + + $ref = new \ReflectionProperty($consumer, 'isRunning'); + $ref->setAccessible(true); + $ref->setValue($consumer, true); + + $consumer->start(); + $this->assertTrue($consumer->isRunning(), "Consumer should still be running"); + } + + /** + * Tests subscribe and unsubscribe method chaining (returns $this). + */ + public function testSubscribeReturnsThis() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'messageListener' => function($msg) { return 0; }, + ]); + + $result = $consumer->subscribe('topic-1', 'tagA'); + $this->assertTrue( + $result === $consumer, + "subscribe should return \$this for chaining" + ); + + $result = $consumer->unsubscribe('topic-1'); + $this->assertTrue( + $result === $consumer, + "unsubscribe should return \$this for chaining" + ); + } + + /** + * Mirrors Java: testSubscribeBeforeStartup + */ + public function testMultipleSubscriptions() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'messageListener' => function($msg) { return 0; }, + ]); + + $consumer->subscribe('topic-1', 'tagA'); + $consumer->subscribe('topic-2', '*'); + $consumer->subscribe('topic-3', 'SQL:age > 10'); + + $expressions = $consumer->getSubscriptionExpressions(); + $this->assertEquals( + 3, + count($expressions), + "Should have 3 subscriptions" + ); + $this->assertEquals( + 'tagA', + $expressions['topic-1'], + "topic-1 expression should be tagA" + ); + $this->assertEquals( + '*', + $expressions['topic-2'], + "topic-2 expression should be *" + ); + } + + /** + * Mirrors Java: testQueryAssignment - verifies queryAssignment internal method. + */ + public function testSubscriptionExpressionsAreStored() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['topic-1' => 'tagA'], + 'messageListener' => function($msg) { return 0; }, + ]); + + $expressions = $consumer->getSubscriptionExpressions(); + $this->assertEquals( + ['topic-1' => 'tagA'], + $expressions, + "Initial expressions should be stored correctly" + ); + } + + /** + * Tests setMessageListener returns $this for chaining. + */ + public function testSetMessageListenerReturnsThis() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group'); + $result = $consumer->setMessageListener(function($msg) { return 0; }); + + $this->assertTrue( + $result === $consumer, + "setMessageListener should return \$this for chaining" + ); + } + + /** + * Tests that FIFO mode can be configured. + */ + public function testFifoModeConfiguration() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'fifo' => true, + 'messageListener' => function($msg) { return 0; }, + ]); + + $ref = new \ReflectionProperty($consumer, 'fifo'); + $ref->setAccessible(true); + $fifo = $ref->getValue($consumer); + + $this->assertTrue($fifo, "FIFO mode should be enabled"); + } + + /** + * Tests getAwaitDuration and getReceiveBatchSize getters. + */ + public function testConsumerGetters() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'awaitDuration' => 15, + 'receiveBatchSize' => 16, + 'messageListener' => function($msg) { return 0; }, + ]); + + $this->assertEquals( + 15, + $consumer->getAwaitDuration(), + "awaitDuration should be 15" + ); + $this->assertEquals( + 16, + $consumer->getReceiveBatchSize(), + "receiveBatchSize should be 16" + ); + $this->assertEquals( + 'test-group', + $consumer->getGroupResource()->getName(), + "consumerGroup should match" + ); + } + + /** + * Tests that getCacheMessageCountThresholdPerQueue distributes evenly. + */ + public function testCacheThresholdDistribution() + { + $consumer = new PushConsumer('127.0.0.1:9876', 'test-group', [ + 'maxCacheMessageCount' => 4096, + 'maxCacheMessageSizeInBytes' => 67108864, + ]); + + $countThreshold = $consumer->getCacheMessageCountThresholdPerQueue(); + $this->assertEquals( + 0, + $countThreshold, + "Count threshold should be 0 with no process queues" + ); + + $bytesThreshold = $consumer->getCacheMessageBytesThresholdPerQueue(); + $this->assertEquals( + 0, + $bytesThreshold, + "Bytes threshold should be 0 with no process queues" + ); + } +} diff --git a/php/tests/ResourceTest.php b/php/tests/ResourceTest.php new file mode 100644 index 000000000..0347250ef --- /dev/null +++ b/php/tests/ResourceTest.php @@ -0,0 +1,93 @@ +setName('foobar'); + $this->assertEquals('foobar', $resource->getName(), "Name should be 'foobar'"); + $this->assertEquals('', $resource->getResourceNamespace(), "Namespace should be empty by default"); + } + + public function testConstructorWithNameAndNamespace() + { + $resource = new Resource(); + $resource->setResourceNamespace('foo'); + $resource->setName('bar'); + $this->assertEquals('bar', $resource->getName(), "Name should be 'bar'"); + $this->assertEquals('foo', $resource->getResourceNamespace(), "Namespace should be 'foo'"); + } + + public function testToProtobuf() + { + $resource = new Resource(); + $resource->setResourceNamespace('foo'); + $resource->setName('bar'); + + $this->assertEquals('foo', $resource->getResourceNamespace(), "Protobuf namespace should be 'foo'"); + $this->assertEquals('bar', $resource->getName(), "Protobuf name should be 'bar'"); + } + + public function testEquals() + { + $resource0 = new Resource(); + $resource0->setResourceNamespace('foo'); + $resource0->setName('bar'); + + $resource1 = new Resource(); + $resource1->setResourceNamespace('foo'); + $resource1->setName('bar'); + + $this->assertEquals( + $resource0->serializeToString(), + $resource1->serializeToString(), + "Same name and namespace should serialize to same value" + ); + + $resource2 = new Resource(); + $resource2->setResourceNamespace('foo0'); + $resource2->setName('bar'); + + $this->assertNotEquals( + $resource0->serializeToString(), + $resource2->serializeToString(), + "Different namespace should serialize to different value" + ); + } + + public function testSetterReturnsThis() + { + $resource = new Resource(); + $result = $resource->setName('test'); + $this->assertTrue($result === $resource, "setName should return \$this for chaining"); + + $result = $resource->setResourceNamespace('ns'); + $this->assertTrue($result === $resource, "setResourceNamespace should return \$this for chaining"); + } +} diff --git a/php/tests/RpcClientManagerTest.php b/php/tests/RpcClientManagerTest.php new file mode 100644 index 000000000..84c2bd084 --- /dev/null +++ b/php/tests/RpcClientManagerTest.php @@ -0,0 +1,598 @@ +assertSame($instance1, $instance2); + $this->assertInstanceOf(RpcClientManager::class, $instance1); + } + + /** + * Test that reset clears singleton + */ + public function testResetClearsSingleton() + { + $instance1 = RpcClientManager::getInstance(); + RpcClientManager::reset(); + $instance2 = RpcClientManager::getInstance(); + + $this->assertNotSame($instance1, $instance2); + } + + /** + * Test getting client creates new connection + */ + public function testGetClientCreatesNewConnection() + { + $manager = RpcClientManager::getInstance(); + $endpoints = 'localhost:8080'; + + $client = $manager->getClient($endpoints); + + $this->assertNotNull($client); + $this->assertEquals(1, $manager->getConnectionCount()); + } + + /** + * Test getting same endpoints returns cached client + */ + public function testGetClientReturnsCachedClient() + { + $manager = RpcClientManager::getInstance(); + $endpoints = 'localhost:8080'; + + $client1 = $manager->getClient($endpoints); + $client2 = $manager->getClient($endpoints); + + $this->assertSame($client1, $client2); + $this->assertEquals(1, $manager->getConnectionCount()); + } + + /** + * Test different endpoints create different clients + */ + public function testDifferentEndpointsCreateDifferentClients() + { + $manager = RpcClientManager::getInstance(); + + $client1 = $manager->getClient('localhost:8080'); + $client2 = $manager->getClient('localhost:9090'); + $client3 = $manager->getClient('remote:8080'); + + $this->assertNotSame($client1, $client2); + $this->assertNotSame($client1, $client3); + $this->assertNotSame($client2, $client3); + $this->assertEquals(3, $manager->getConnectionCount()); + } + + /** + * Test releasing specific client + */ + public function testReleaseClient() + { + $manager = RpcClientManager::getInstance(); + + $manager->getClient('localhost:8080'); + $manager->getClient('localhost:9090'); + $this->assertEquals(2, $manager->getConnectionCount()); + + $manager->releaseClient('localhost:8080'); + $this->assertEquals(1, $manager->getConnectionCount()); + } + + /** + * Test releasing all clients + */ + public function testReleaseAllClients() + { + $manager = RpcClientManager::getInstance(); + + $manager->getClient('localhost:8080'); + $manager->getClient('localhost:9090'); + $manager->getClient('remote:8080'); + $this->assertEquals(3, $manager->getConnectionCount()); + + $manager->releaseAll(); + $this->assertEquals(0, $manager->getConnectionCount()); + } + + /** + * Test releasing non-existent client doesn't cause errors + */ + public function testReleaseNonExistentClient() + { + $manager = RpcClientManager::getInstance(); + + // Should not throw exception + $manager->releaseClient('nonexistent:8080'); + $this->assertEquals(0, $manager->getConnectionCount()); + } + + /** + * Test client with insecure credentials + */ + public function testClientWithInsecureCredentials() + { + $manager = RpcClientManager::getInstance(); + $tlsCredentials = TlsCredentials::createInsecure(); + + $client1 = $manager->getClient('localhost:8080', ['tlsCredentials' => $tlsCredentials]); + $client2 = $manager->getClient('localhost:8080', ['tlsCredentials' => $tlsCredentials]); + + $this->assertSame($client1, $client2); + $this->assertEquals(1, $manager->getConnectionCount()); + } + + /** + * Test client with secure credentials creates separate connection + */ + public function testSecureAndInsecureCreateSeparateConnections() + { + $manager = RpcClientManager::getInstance(); + + // Explicit insecure credentials + $insecureCreds = TlsCredentials::createInsecure(); + $client1 = $manager->getClient('localhost:8080', ['tlsCredentials' => $insecureCreds]); + + // No options defaults to TLS (SSL enabled by default) + $client2 = $manager->getClient('localhost:8080'); + + // Different transport modes must never share a channel + $this->assertNotSame($client1, $client2); + $this->assertEquals(2, $manager->getConnectionCount()); + } + + /** + * Test different TLS configurations create separate connections + */ + public function testDifferentTlsConfigsCreateSeparateConnections() + { + $manager = RpcClientManager::getInstance(); + + $insecure = TlsCredentials::createInsecure(); + $client1 = $manager->getClient('localhost:8080', ['tlsCredentials' => $insecure]); + + // Note: We can't easily test mTLS without actual cert files, + // but we can verify the key generation logic works + + $this->assertEquals(1, $manager->getConnectionCount()); + } + + /** + * Test connection count tracking + */ + public function testConnectionCountTracking() + { + $manager = RpcClientManager::getInstance(); + + $this->assertEquals(0, $manager->getConnectionCount()); + + $manager->getClient('endpoint1:8080'); + $this->assertEquals(1, $manager->getConnectionCount()); + + $manager->getClient('endpoint2:8080'); + $this->assertEquals(2, $manager->getConnectionCount()); + + $manager->getClient('endpoint3:8080'); + $this->assertEquals(3, $manager->getConnectionCount()); + + $manager->releaseClient('endpoint2:8080'); + $this->assertEquals(2, $manager->getConnectionCount()); + } + + /** + * Test that options affect client caching key + */ + public function testOptionsAffectCachingKey() + { + $manager = RpcClientManager::getInstance(); + + // Same endpoint, no options + $client1 = $manager->getClient('localhost:8080'); + + // Same endpoint, with empty options array + $client2 = $manager->getClient('localhost:8080', []); + + // These should be the same client (empty options don't change key) + $this->assertSame($client1, $client2); + } + + /** + * Test idle timeout configuration + */ + public function testIdleTimeoutConfiguration() + { + $manager = RpcClientManager::getInstance(); + + // Get client to initialize it + $manager->getClient('localhost:8080'); + + // Use reflection to check default values + $reflection = new \ReflectionClass($manager); + + $idleTimeoutProp = $reflection->getProperty('idleTimeoutSeconds'); + $idleTimeoutProp->setAccessible(true); + $this->assertEquals(1800, $idleTimeoutProp->getValue($manager)); // 30 minutes + + $checkIntervalProp = $reflection->getProperty('checkIntervalSeconds'); + $checkIntervalProp->setAccessible(true); + $this->assertEquals(60, $checkIntervalProp->getValue($manager)); // 1 minute + } + + /** + * Test last used time is updated on access + */ + public function testLastUsedTimeUpdated() + { + $manager = RpcClientManager::getInstance(); + + $manager->getClient('localhost:8080'); + + // Use reflection to check last used time + $reflection = new \ReflectionClass($manager); + $lastUsedProp = $reflection->getProperty('clientLastUsedTime'); + $lastUsedProp->setAccessible(true); + $lastUsedTimes = $lastUsedProp->getValue($manager); + + $this->assertCount(1, $lastUsedTimes); + $this->assertIsInt(reset($lastUsedTimes)); + } + + /** + * Test multiple accesses update last used time + */ + public function testMultipleAccessesUpdateLastUsedTime() + { + $manager = RpcClientManager::getInstance(); + + $manager->getClient('localhost:8080'); + sleep(1); // Wait 1 second + $manager->getClient('localhost:8080'); + + $reflection = new \ReflectionClass($manager); + $lastUsedProp = $reflection->getProperty('clientLastUsedTime'); + $lastUsedProp->setAccessible(true); + $lastUsedTimes = $lastUsedProp->getValue($manager); + + $this->assertCount(1, $lastUsedTimes); + // Last used time should be recent (within 2 seconds) + $lastTime = reset($lastUsedTimes); + $this->assertGreaterThan(time() - 2, $lastTime); + } + + /** + * Test cleanup idle clients removes old connections + */ + public function testCleanupIdleClients() + { + $manager = RpcClientManager::getInstance(); + + // Create some clients + $manager->getClient('endpoint1:8080'); + $manager->getClient('endpoint2:8080'); + $this->assertEquals(2, $manager->getConnectionCount()); + + // Use reflection to manipulate last used times + $reflection = new \ReflectionClass($manager); + $lastUsedProp = $reflection->getProperty('clientLastUsedTime'); + $lastUsedProp->setAccessible(true); + + // Make one client appear very old (older than 1800 seconds) + $oldTime = time() - 2000; + $lastUsedTimes = $lastUsedProp->getValue($manager); + $keys = array_keys($lastUsedTimes); + if (!empty($keys)) { + $lastUsedTimes[$keys[0]] = $oldTime; + $lastUsedProp->setValue($manager, $lastUsedTimes); + } + + // Force cleanup by manipulating last check time + $lastCheckProp = $reflection->getProperty('lastCheckTime'); + $lastCheckProp->setAccessible(true); + $lastCheckProp->setValue($manager, time() - 100); // Force check + + // Access client to trigger cleanup + $manager->getClient('endpoint3:8080'); + + // Should have cleaned up the old client + $this->assertLessThanOrEqual(2, $manager->getConnectionCount()); + } + + /** + * Test that active clients are not cleaned up + */ + public function testActiveClientsNotCleanedUp() + { + $manager = RpcClientManager::getInstance(); + + $manager->getClient('localhost:8080'); + $initialCount = $manager->getConnectionCount(); + + // Access again immediately (should still be active) + $manager->getClient('localhost:8080'); + + $this->assertEquals($initialCount, $manager->getConnectionCount()); + } + + /** + * Test logger is initialized + */ + public function testLoggerInitialized() + { + $manager = RpcClientManager::getInstance(); + + $reflection = new \ReflectionClass($manager); + $loggerProp = $reflection->getProperty('logger'); + $loggerProp->setAccessible(true); + $logger = $loggerProp->getValue($manager); + + $this->assertNotNull($logger); + } + + /** + * Test endpoint with port variations + */ + public function testEndpointWithPortVariations() + { + $manager = RpcClientManager::getInstance(); + + $client1 = $manager->getClient('localhost:8080'); + $client2 = $manager->getClient('localhost:8081'); + $client3 = $manager->getClient('localhost:9090'); + + $this->assertNotSame($client1, $client2); + $this->assertNotSame($client1, $client3); + $this->assertNotSame($client2, $client3); + $this->assertEquals(3, $manager->getConnectionCount()); + } + + /** + * Test IP address endpoints + */ + public function testIpAddressEndpoints() + { + $manager = RpcClientManager::getInstance(); + + $client1 = $manager->getClient('127.0.0.1:8080'); + $client2 = $manager->getClient('192.168.1.100:8080'); + + $this->assertNotSame($client1, $client2); + $this->assertEquals(2, $manager->getConnectionCount()); + } + + /** + * Test IPv6 endpoints + */ + public function testIpv6Endpoints() + { + $manager = RpcClientManager::getInstance(); + + $client1 = $manager->getClient('[::1]:8080'); + $client2 = $manager->getClient('[2001:db8::1]:8080'); + + $this->assertNotSame($client1, $client2); + $this->assertEquals(2, $manager->getConnectionCount()); + } + + /** + * Test domain name endpoints + */ + public function testDomainNameEndpoints() + { + $manager = RpcClientManager::getInstance(); + + $client1 = $manager->getClient('rocketmq.example.com:8080'); + $client2 = $manager->getClient('mq.prod.internal:9090'); + + $this->assertNotSame($client1, $client2); + $this->assertEquals(2, $manager->getConnectionCount()); + } + + /** + * Test concurrent access to same endpoint + */ + public function testConcurrentAccessToSameEndpoint() + { + $manager = RpcClientManager::getInstance(); + + // Simulate multiple "concurrent" requests + $clients = []; + for ($i = 0; $i < 10; $i++) { + $clients[] = $manager->getClient('localhost:8080'); + } + + // All should be the same instance + foreach ($clients as $client) { + $this->assertSame($clients[0], $client); + } + + $this->assertEquals(1, $manager->getConnectionCount()); + } + + /** + * Test many different endpoints + */ + public function testManyDifferentEndpoints() + { + $manager = RpcClientManager::getInstance(); + + $count = 50; + for ($i = 0; $i < $count; $i++) { + $manager->getClient("endpoint{$i}:8080"); + } + + $this->assertEquals($count, $manager->getConnectionCount()); + } + + /** + * Test release and recreate + */ + public function testReleaseAndRecreate() + { + $manager = RpcClientManager::getInstance(); + + $client1 = $manager->getClient('localhost:8080'); + $manager->releaseClient('localhost:8080'); + + $this->assertEquals(0, $manager->getConnectionCount()); + + $client2 = $manager->getClient('localhost:8080'); + $this->assertEquals(1, $manager->getConnectionCount()); + + // New client should be different instance + $this->assertNotSame($client1, $client2); + } + + /** + * Test makeKey generates consistent keys + */ + public function testMakeKeyConsistency() + { + $manager = RpcClientManager::getInstance(); + + // Use reflection to access private makeKey method + $reflection = new \ReflectionClass($manager); + $makeKeyMethod = $reflection->getMethod('makeKey'); + $makeKeyMethod->setAccessible(true); + + $key1 = $makeKeyMethod->invoke($manager, 'localhost:8080', []); + $key2 = $makeKeyMethod->invoke($manager, 'localhost:8080', []); + + $this->assertEquals($key1, $key2); + } + + /** + * Test makeKey includes TLS fingerprint + */ + public function testMakeKeyIncludesTlsFingerprint() + { + $manager = RpcClientManager::getInstance(); + + $reflection = new \ReflectionClass($manager); + $makeKeyMethod = $reflection->getMethod('makeKey'); + $makeKeyMethod->setAccessible(true); + + $insecure = TlsCredentials::createInsecure(); + $key1 = $makeKeyMethod->invoke($manager, 'localhost:8080', ['tlsCredentials' => $insecure]); + $key2 = $makeKeyMethod->invoke($manager, 'localhost:8080', []); + + // No options resolves to default TLS, which must not collide with insecure + $this->assertNotEquals($key1, $key2); + } + + /** + * Test makeKey reflects the resolved transport mode for sslEnabled + */ + public function testMakeKeyIncludesSslEnabledMode() + { + $manager = RpcClientManager::getInstance(); + + $reflection = new \ReflectionClass($manager); + $makeKeyMethod = $reflection->getMethod('makeKey'); + $makeKeyMethod->setAccessible(true); + + $defaultKey = $makeKeyMethod->invoke($manager, 'localhost:8080', []); + $sslOnKey = $makeKeyMethod->invoke($manager, 'localhost:8080', ['sslEnabled' => true]); + $sslOffKey = $makeKeyMethod->invoke($manager, 'localhost:8080', ['sslEnabled' => false]); + + // Default equals explicit sslEnabled=true (both resolve to default TLS) + $this->assertEquals($defaultKey, $sslOnKey); + // Plaintext mode must get its own cache key + $this->assertNotEquals($defaultKey, $sslOffKey); + // sslEnabled=false shares the key with explicit insecure TlsCredentials + $insecure = TlsCredentials::createInsecure(); + $insecureKey = $makeKeyMethod->invoke($manager, 'localhost:8080', ['tlsCredentials' => $insecure]); + $this->assertEquals($insecureKey, $sslOffKey); + } + + /** + * Test resolveCredentials returns insecure by default + */ + public function testResolveCredentialsDefault() + { + $manager = RpcClientManager::getInstance(); + + $reflection = new \ReflectionClass($manager); + $resolveMethod = $reflection->getMethod('resolveCredentials'); + $resolveMethod->setAccessible(true); + + $credentials = $resolveMethod->invoke($manager, []); + + // Default is ChannelCredentials::createInsecure(), which may return null in some gRPC versions + // Just verify it doesn't throw an exception + $this->assertTrue(true); + } + + /** + * Test resolveCredentials with TLS credentials + */ + public function testResolveCredentialsWithTls() + { + $manager = RpcClientManager::getInstance(); + + $reflection = new \ReflectionClass($manager); + $resolveMethod = $reflection->getMethod('resolveCredentials'); + $resolveMethod->setAccessible(true); + + $tlsCreds = TlsCredentials::createInsecure(); + $credentials = $resolveMethod->invoke($manager, ['tlsCredentials' => $tlsCreds]); + + // Verify it doesn't throw an exception + $this->assertTrue(true); + } +} diff --git a/php/tests/SendMessageHandlerRetryTest.php b/php/tests/SendMessageHandlerRetryTest.php new file mode 100644 index 000000000..20d832a01 --- /dev/null +++ b/php/tests/SendMessageHandlerRetryTest.php @@ -0,0 +1,265 @@ +response, $this->status]; + } +} + +/** + * Tests for SendMessageHandler::sendMessageWithRetry() covering: + * - transaction (half) messages keep the TRANSACTION type when the request is + * rebuilt for a retry + * - the result reports the endpoint of the queue that actually succeeded + */ +class SendMessageHandlerRetryTest extends TestCase +{ + /** @var SendMessageRequest[] */ + private array $capturedRequests = []; + + private function buildQueue(string $brokerName, string $host, int $port): MessageQueue + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $address = new Address(); + $address->setHost($host); + $address->setPort($port); + $endpoints = new Endpoints(); + $endpoints->setAddresses([$address]); + + $broker = new Broker(); + $broker->setName($brokerName); + $broker->setId(0); + $broker->setEndpoints($endpoints); + + $queue = new MessageQueue(); + $queue->setTopic($topic); + $queue->setId(0); + $queue->setBroker($broker); + + return $queue; + } + + private function buildMessage(): Message + { + $topic = new Resource(); + $topic->setName('test-topic'); + + $message = new Message(); + $message->setTopic($topic); + $message->setBody('half message body'); + + return $message; + } + + private function successResponse(): array + { + $status = new \stdClass(); + $status->code = 0; + $status->details = ''; + + $respStatus = new Status(); + $respStatus->setCode(20000); + + $entryStatus = new Status(); + $entryStatus->setCode(20000); + + $entry = new SendResultEntry(); + $entry->setMessageId('msg-001'); + $entry->setTransactionId('tx-001'); + $entry->setStatus($entryStatus); + + $response = new SendMessageResponse(); + $response->setStatus($respStatus); + $response->setEntries([$entry]); + + return [$response, $status]; + } + + private function failedTransportStatus(): array + { + $status = new \stdClass(); + $status->code = 14; // UNAVAILABLE + $status->details = 'transient transport failure'; + return [null, $status]; + } + + /** + * Build a handler whose client fails the first N attempts and then succeeds, + * capturing every request for inspection. + */ + private function buildHandler(int $failuresBeforeSuccess): SendMessageHandler + { + $this->capturedRequests = []; + $callCount = 0; + + $client = $this->createMock(MessagingServiceClient::class); + $client->method('SendMessage')->willReturnCallback( + function ($request) use (&$callCount, $failuresBeforeSuccess) { + $this->capturedRequests[] = $request; + $callCount++; + if ($callCount <= $failuresBeforeSuccess) { + [$response, $status] = $this->failedTransportStatus(); + } else { + [$response, $status] = $this->successResponse(); + } + return new FakeSendCall($response, $status); + } + ); + + $routeManager = $this->createMock(PublishingRouteManager::class); + + return new SendMessageHandler( + $client, + new ProducerSettings('fake-endpoint:8081', ['maxAttempts' => 3]), + new MessageValidator(4194304, false), + $routeManager, + function (string $hookPoint, array $context): void {}, + function (?int $timeoutMs): array { return []; }, + function (?int $overrideTimeout): array { return []; }, + function (string $operation): int { return 60_000_000; } + ); + } + + /** + * A transaction (half) message that fails transiently must be retried with + * the TRANSACTION message type, never as a normal visible message. + */ + public function testRetryPreservesTransactionMessageType() + { + $handler = $this->buildHandler(1); + $message = $this->buildMessage(); + $candidates = [ + $this->buildQueue('broker-0', 'host-0', 8081), + $this->buildQueue('broker-1', 'host-1', 8081), + $this->buildQueue('broker-2', 'host-2', 8081), + ]; + + $request = $handler->wrapTransactionMessageRequest([$message], $candidates[0]); + $result = $handler->sendMessageWithRetry($request, $message, $candidates, 3, true); + + $this->assertCount(2, $this->capturedRequests, "Should have failed once and retried once"); + foreach ($this->capturedRequests as $i => $captured) { + $sentType = $captured->getMessages()[0]->getSystemProperties()->getMessageType(); + $this->assertEquals( + MessageType::TRANSACTION, + $sentType, + "Attempt " . ($i + 1) . " must carry the TRANSACTION message type" + ); + } + $this->assertEquals('tx-001', $result['transactionId']); + } + + /** + * Without txEnabled, retried requests keep the NORMAL message type. + */ + public function testRetryKeepsNormalMessageTypeByDefault() + { + $handler = $this->buildHandler(1); + $message = $this->buildMessage(); + $candidates = [ + $this->buildQueue('broker-0', 'host-0', 8081), + $this->buildQueue('broker-1', 'host-1', 8081), + $this->buildQueue('broker-2', 'host-2', 8081), + ]; + + $request = $handler->wrapSendMessageRequest([$message], $candidates[0]); + $handler->sendMessageWithRetry($request, $message, $candidates, 3); + + $this->assertCount(2, $this->capturedRequests); + $retriedType = $this->capturedRequests[1]->getMessages()[0]->getSystemProperties()->getMessageType(); + $this->assertEquals(MessageType::NORMAL, $retriedType); + } + + /** + * The result must report the endpoint of the queue that actually succeeded, + * which after a retry differs from the first candidate. + */ + public function testResultReportsSuccessfulQueueEndpoints() + { + $handler = $this->buildHandler(1); + $message = $this->buildMessage(); + $candidates = [ + $this->buildQueue('broker-0', 'host-0', 8081), + $this->buildQueue('broker-1', 'host-1', 8081), + $this->buildQueue('broker-2', 'host-2', 8081), + ]; + + $request = $handler->wrapTransactionMessageRequest([$message], $candidates[0]); + $result = $handler->sendMessageWithRetry($request, $message, $candidates, 3, true); + + // Attempt 2 rotates to candidates[IntMath::mod(2, 3)] = candidates[2] + $this->assertArrayHasKey('endpoints', $result); + $this->assertNotNull($result['endpoints']); + $this->assertEquals('host-2', $result['endpoints']->getAddresses()[0]->getHost()); + } + + /** + * On first-attempt success the reported endpoint is the first candidate's. + */ + public function testResultReportsFirstQueueEndpointsWithoutRetry() + { + $handler = $this->buildHandler(0); + $message = $this->buildMessage(); + $candidates = [ + $this->buildQueue('broker-0', 'host-0', 8081), + $this->buildQueue('broker-1', 'host-1', 8081), + ]; + + $request = $handler->wrapSendMessageRequest([$message], $candidates[0]); + $result = $handler->sendMessageWithRetry($request, $message, $candidates, 3); + + $this->assertCount(1, $this->capturedRequests); + $this->assertEquals('host-0', $result['endpoints']->getAddresses()[0]->getHost()); + } +} diff --git a/php/tests/SignatureTest.php b/php/tests/SignatureTest.php new file mode 100644 index 000000000..c7ad76a8a --- /dev/null +++ b/php/tests/SignatureTest.php @@ -0,0 +1,262 @@ +assertNotNull($metadata, "Metadata should not be empty"); + $this->assertEquals( + 'test-client-id', + $metadata['x-mq-client-id'][0], + "Client ID should match" + ); + $this->assertEquals( + 'PHP', + $metadata['x-mq-language'][0], + "Language should be PHP" + ); + $this->assertEquals( + 'v2', + $metadata['x-mq-protocol'][0], + "Protocol should be v2" + ); + $this->assertEquals( + 'my-namespace', + $metadata['x-mq-namespace'][0], + "Namespace should match" + ); + + // Without credentials, no authorization header + $this->assertFalse( + isset($metadata['authorization']), + "No authorization header without credentials" + ); + $this->assertFalse( + isset($metadata['x-mq-session-token']), + "No session token without credentials" + ); + } + + /** + * Verify sign() with credentials produces authorization header. + */ + public function testSignWithCredentials() + { + $credentials = new SessionCredentials('ak-12345', 'sk-67890'); + + $metadata = Signature::sign( + $credentials, + 'test-client-id', + 'PHP', + '5.0.0', + '', + 'v2' + ); + + $this->assertNotNull($metadata, "Metadata should not be empty"); + + // Authorization header must be present + $this->assertTrue( + isset($metadata['authorization']), + "Authorization header should be present with credentials" + ); + + $auth = $metadata['authorization'][0]; + $this->assertTrue( + strpos($auth, 'MQv2-HMAC-SHA1') !== false, + "Authorization should contain algorithm" + ); + $this->assertTrue( + strpos($auth, 'Credential=ak-12345') !== false, + "Authorization should contain access key" + ); + $this->assertTrue( + strpos($auth, 'SignedHeaders=x-mq-date-time') !== false, + "Authorization should contain signed headers" + ); + $this->assertTrue( + strpos($auth, 'Signature=') !== false, + "Authorization should contain signature" + ); + } + + /** + * Verify sign() with STS token adds x-mq-session-token header. + */ + public function testSignWithSecurityToken() + { + $credentials = new SessionCredentials('ak-sts', 'sk-sts', 'sts-token-xyz'); + + $metadata = Signature::sign( + $credentials, + 'test-client-id', + 'PHP', + '5.0.0', + '', + 'v2' + ); + + $this->assertTrue( + isset($metadata['x-mq-session-token']), + "x-mq-session-token should be present with STS credentials" + ); + $this->assertEquals( + 'sts-token-xyz', + $metadata['x-mq-session-token'][0], + "Session token value should match" + ); + } + + /** + * Verify x-mq-date-time format is YYYYMMDDTHHmmssZ. + */ + public function testDateTimeFormat() + { + $metadata = Signature::sign( + null, + 'test-client-id' + ); + + $dateTime = $metadata['x-mq-date-time'][0]; + + // Should match pattern like: 20260519T123456Z + $this->assertTrue( + preg_match('/^\d{8}T\d{6}Z$/', $dateTime) === 1, + "DateTime should be in YYYYMMDDTHHmmssZ format (got: {$dateTime})" + ); + } + + /** + * Verify request-id is a UUID-like format. + */ + public function testRequestIdFormat() + { + $metadata = Signature::sign( + null, + 'test-client-id' + ); + + $requestId = $metadata['x-mq-request-id'][0]; + + $this->assertTrue( + preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $requestId) === 1, + "RequestId should be UUID-like (got: {$requestId})" + ); + } + + /** + * Verify HMAC-SHA1 signature is deterministic for the same datetime. + */ + public function testSignatureIsDeterministic() + { + $credentials = new SessionCredentials('ak-test', 'sk-secret'); + + // Sign twice quickly (same second should produce same datetime) + $metadata1 = Signature::sign($credentials, 'test-client'); + + // We can't guarantee same datetime, but we can verify structure + $auth1 = $metadata1['authorization'][0]; + $this->assertTrue( + preg_match('/MQv2-HMAC-SHA1.*Signature=[0-9A-F]{40}$/', $auth1) === 1, + "Signature should be 40 hex chars (SHA1)" + ); + } + + /** + * Verify SessionCredentials rejects empty access key. + */ + public function testSessionCredentialsRejectsEmptyAccessKey() + { + $this->expectException(\InvalidArgumentException::class); + new SessionCredentials('', 'sk-test'); + } + + /** + * Verify SessionCredentials rejects empty secret key. + */ + public function testSessionCredentialsRejectsEmptySecretKey() + { + $this->expectException(\InvalidArgumentException::class); + new SessionCredentials('ak-test', ''); + } + + /** + * Verify SessionCredentials stores all three fields. + */ + public function testSessionCredentialsStoresAll() + { + $credentials = new SessionCredentials('ak-val', 'sk-val', 'sts-val'); + + $this->assertEquals( + 'ak-val', + $credentials->getAccessKey(), + "Access key should match" + ); + $this->assertEquals( + 'sk-val', + $credentials->getAccessSecret(), + "Secret key should match" + ); + $this->assertEquals( + 'sts-val', + $credentials->getSecurityToken(), + "Security token should match" + ); + } + + /** + * Verify SessionCredentials without STS token returns null. + */ + public function testSessionCredentialsWithoutSts() + { + $credentials = new SessionCredentials('ak-val', 'sk-val'); + + $this->assertNull( + $credentials->getSecurityToken(), + "Security token should be null when not provided" + ); + } +} + diff --git a/php/tests/SimpleConsumerTest.php b/php/tests/SimpleConsumerTest.php new file mode 100644 index 000000000..611d38345 --- /dev/null +++ b/php/tests/SimpleConsumerTest.php @@ -0,0 +1,270 @@ +expectException(\RuntimeException::class); + $consumer->start(); + } + + /** + * Mirrors Java: testReceiveWithoutStart + */ + public function testReceiveWithoutStart() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->expectException(\RuntimeException::class); + $consumer->receive(10); + } + + /** + * Mirrors Java: testAckWithoutStart + */ + public function testAckWithoutStart() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->expectException(\RuntimeException::class); + $consumer->ack([new \stdClass()]); + } + + /** + * Mirrors Java: testSubscribeWithoutStart + */ + public function testSubscribeWithoutStart() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group'); + + $this->expectException(\RuntimeException::class); + $consumer->subscribe('test-topic', '*'); + } + + /** + * Mirrors Java: testUnsubscribeWithoutStart + */ + public function testUnsubscribeWithoutStart() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group'); + + $this->expectException(\RuntimeException::class); + $consumer->unsubscribe('test-topic'); + } + + /** + * Mirrors Java: testReceiveAsyncWithZeroMaxMessageNum + */ + public function testReceiveWithZeroMaxMessageNum() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->setRunning($consumer, true); + + $this->expectException(\InvalidArgumentException::class); + $consumer->receive(0); + } + + /** + * Mirrors Java: testReceiveWithNegativeMaxMessageNum. + */ + public function testReceiveWithNegativeMaxMessageNum() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->setRunning($consumer, true); + + $this->expectException(\InvalidArgumentException::class); + $consumer->receive(-1); + } + + /** + * Tests that changeInvisibleDuration without start throws. + */ + public function testChangeInvisibleDurationWithoutStart() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->expectException(\RuntimeException::class); + $consumer->changeInvisibleDuration(new \stdClass(), 30); + } + + /** + * Tests that subscriptions returns $this for method chaining. + */ + public function testSubscribeReturnsThis() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group'); + $this->setRunning($consumer, true); + + $result = $consumer->subscribe('test-topic', '*'); + $this->assertSame($consumer, $result, "subscribe should return \$this for chaining"); + } + + /** + * Tests unsubscribe method chaining. + */ + public function testUnsubscribeReturnsThis() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group'); + + $this->setRunning($consumer, true); + + $result = $consumer->unsubscribe('test-topic'); + $this->assertTrue( + $result === $consumer, + "unsubscribe should return \$this for chaining" + ); + } + + /** + * Tests multiple subscriptions can coexist and be enumerated. + */ + public function testMultipleSubscriptions() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group'); + $this->setRunning($consumer, true); + + $consumer->subscribe('topic-1', 'tagA'); + $consumer->subscribe('topic-2', '*'); + $expressions = $consumer->getSubscriptionExpressions(); + $this->assertEquals(2, count($expressions), 'Should have 2 subscriptions after two subscribe calls'); + + $this->assertTrue( + isset($expressions['topic-1']), + 'topic-1 should be subscriptions' + ); + + $this->assertTrue( + isset($expressions['topic-2']), + 'topic-2 should be subscriptions' + ); + $this->assertEquals('tagA', $expressions['topic-1'], "topic-1 should have tag 'tagA'"); + $this->assertEquals('*', $expressions['topic-2'], "topic-2 should have tag '*'"); + } + + /** + * Tests awaitDuration configuration. + */ + public function testAwaitDurationConfiguration() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group', [ + 'awaitDuration' => 60, + ]); + + $ref = new \ReflectionProperty($consumer, 'awaitDuration'); + $ref->setAccessible(true); + $actual = $ref->getValue($consumer); + + $this->assertEquals(60, $actual, "awaitDuration should be 60"); + } + + /** + * Tests clientId is set correctly. + */ + public function testGetClientId() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group', [ + 'clientId' => 'custom-client-id', + ]); + + $clientId = $consumer->getClientId(); + $this->assertEquals( + 'custom-client-id', + $clientId, + "ClientId should match configured value" + ); + } + + /** + * Tests getConsumerGroup returns the configured value. + */ + public function testGetConsumerGroup() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'my-consumer-group'); + + $this->assertEquals( + 'my-consumer-group', + $consumer->getConsumerGroup(), + "ConsumerGroup should match configured value" + ); + } + + /** + * Tests subscribe stores the subscription and returns $this for chaining. + */ + public function testSubscribeStoresAndReturnsThis() + { + $consumer = new SimpleConsumer('127.0.0.1:9876', 'test-group'); + $this->setRunning($consumer, true); + $result = $consumer->subscribe('topic-a', '*'); + $this->assertSame($consumer, $result, "subscribe should return \$this for chaining"); + $result2 = $consumer->subscribe('topic-b', 'tagX'); + $this->assertSame($consumer, $result2, "subscribe should return \$this for chaining"); + + $expressions = $consumer->getSubscriptionExpressions(); + $this->assertCount(2, $expressions, "should have 2 subscriptions after two subscribe calls"); + $this->assertEquals("*", $expressions['topic-a'], "topic-a should have tag '*'"); + $this->assertEquals( + 'tagX', $expressions['topic-b'], "topic-b should have tag 'tagX'" + ); + } + + private function setRunning($consumer, bool $running): void + { + $ref = new \ReflectionProperty($consumer, 'isStarted'); + $ref->setAccessible(true); + $ref->setValue($consumer, $running); + } +} diff --git a/php/tests/SipHash24Test.php b/php/tests/SipHash24Test.php new file mode 100644 index 000000000..a91c880d8 --- /dev/null +++ b/php/tests/SipHash24Test.php @@ -0,0 +1,445 @@ + '726fdb47dd0e0e31', + 1 => '74f839c593dc67fd', + 2 => '0d6c8009d9a94f5a', + 3 => '85676696d7fb7e2d', + 4 => 'cf2794e0277187b7', + 5 => '18765564cd99a68d', + 6 => 'cbc9466e58fee3ce', + 7 => 'ab0200f58b01d137', + 8 => '93f5f5799a932462', + 15 => 'a129ca6149be45e5', + ]; + + foreach ($vectors as $length => $expectedHex) { + $input = ''; + for ($i = 0; $i < $length; $i++) { + $input .= chr($i); + } + $this->assertSame( + self::hexToInt64($expectedHex), + SipHash24::hash($input), + "Reference vector mismatch for input length {$length}" + ); + } + } + + /** + * Test Guava compatibility for message group strings: expected values equal + * Hashing.sipHash24().hashBytes(group.getBytes(UTF_8)).asLong() in the Java + * client (and the Node.js siphash24 result), generated with an independent + * SipHash-2-4 reference implementation. + */ + public function testGuavaCompatibleMessageGroupHashes() + { + $expected = [ + 'message-group-0' => -9040379075287310735, + 'message-group-1' => 7213479487730864356, + 'fifo-group' => -6665943517282973836, + 'order-12345' => 2790219758358240578, + 'RocketMQ' => -4355033068952641245, + '中文分组' => 7354208218558947199, + ]; + + foreach ($expected as $group => $hash) { + $this->assertSame( + $hash, + SipHash24::hash($group), + "Guava-compatible hash mismatch for group '{$group}'" + ); + } + } + + /** + * Test basic hashing with default key (Guava's fixed key bytes 00..0f) + */ + public function testHashWithDefaultKey() + { + $hash1 = SipHash24::hash("test"); + $hash2 = SipHash24::hash("test"); + + // Same input should produce same hash (deterministic) + $this->assertEquals($hash1, $hash2); + $this->assertIsInt($hash1); + } + + /** + * Test that different inputs produce different hashes + */ + public function testDifferentInputsProduceDifferentHashes() + { + $hash1 = SipHash24::hash("test1"); + $hash2 = SipHash24::hash("test2"); + $hash3 = SipHash24::hash("different"); + + $this->assertNotEquals($hash1, $hash2); + $this->assertNotEquals($hash2, $hash3); + $this->assertNotEquals($hash1, $hash3); + } + + /** + * Test empty string hashing + */ + public function testHashEmptyString() + { + $hash = SipHash24::hash(""); + $this->assertIsInt($hash); + + // Empty string should always produce the same hash + $hash2 = SipHash24::hash(""); + $this->assertEquals($hash, $hash2); + } + + /** + * Test hashing with custom keys + */ + public function testHashWithCustomKeys() + { + $data = "test data"; + + $hash1 = (new SipHash24(0, 0))->hashBytes($data); + $hash2 = (new SipHash24(1, 0))->hashBytes($data); + $hash3 = (new SipHash24(0, 1))->hashBytes($data); + $hash4 = (new SipHash24(123456, 789012))->hashBytes($data); + + // Different keys should produce different hashes for same data + $this->assertNotEquals($hash1, $hash2); + $this->assertNotEquals($hash1, $hash3); + $this->assertNotEquals($hash2, $hash3); + $this->assertNotEquals($hash1, $hash4); + } + + /** + * Test that same key produces consistent results + */ + public function testConsistentHashingWithSameKey() + { + $data = "consistent test"; + $sipHash = new SipHash24(0x12345678, 0x9ABCDEF0); + + $hash1 = $sipHash->hashBytes($data); + $hash2 = $sipHash->hashBytes($data); + $hash3 = $sipHash->hashBytes($data); + + $this->assertEquals($hash1, $hash2); + $this->assertEquals($hash2, $hash3); + } + + /** + * Test hashing single character + */ + public function testHashSingleCharacter() + { + $hashA = SipHash24::hash("a"); + $hashB = SipHash24::hash("b"); + + $this->assertNotEquals($hashA, $hashB); + $this->assertIsInt($hashA); + $this->assertIsInt($hashB); + } + + /** + * Test hashing long strings + */ + public function testHashLongString() + { + $longString = str_repeat("abcdefghijklmnopqrstuvwxyz", 100); // 2600 chars + $hash = SipHash24::hash($longString); + + $this->assertIsInt($hash); + + // Should be deterministic + $hash2 = SipHash24::hash($longString); + $this->assertEquals($hash, $hash2); + } + + /** + * Test hashing binary data + */ + public function testHashBinaryData() + { + $binaryData = pack("C*", range(0, 255)); // All byte values + $hash = SipHash24::hash($binaryData); + + $this->assertIsInt($hash); + + // Same binary data should produce same hash + $hash2 = SipHash24::hash($binaryData); + $this->assertEquals($hash, $hash2); + } + + /** + * Test hashing null bytes + */ + public function testHashNullBytes() + { + $nullBytes = "\0\0\0\0"; + $hash = SipHash24::hash($nullBytes); + + $this->assertIsInt($hash); + + // Different from empty string + $emptyHash = SipHash24::hash(""); + $this->assertNotEquals($hash, $emptyHash); + } + + /** + * Test hashing special characters + */ + public function testHashSpecialCharacters() + { + $special = "!@#$%^&*()_+-=[]{}|;':\",./<>?"; + $hash = SipHash24::hash($special); + + $this->assertIsInt($hash); + + // Should be different from regular text + $normalHash = SipHash24::hash("normal text"); + $this->assertNotEquals($hash, $normalHash); + } + + /** + * Test hashing Unicode/UTF-8 strings + */ + public function testHashUnicodeString() + { + $unicode = "你好世界🌍Hello世界"; + $hash = SipHash24::hash($unicode); + + $this->assertIsInt($hash); + + // Should be deterministic + $hash2 = SipHash24::hash($unicode); + $this->assertEquals($hash, $hash2); + } + + /** + * Test that hash values are within 64-bit range + */ + public function testHashValueRange() + { + $testStrings = [ + "short", + "medium length string", + "very long string " . str_repeat("x", 1000), + "", + "a", + ]; + + foreach ($testStrings as $str) { + $hash = SipHash24::hash($str); + + // On 64-bit PHP, should be a valid integer + if (PHP_INT_SIZE >= 8) { + $this->assertIsInt($hash); + } else { + // On 32-bit PHP, might be float due to large numbers + $this->assertTrue(is_int($hash) || is_float($hash)); + } + } + } + + /** + * Test multiple instances with same key produce same results + */ + public function testMultipleInstancesConsistency() + { + $data = "test consistency"; + $key0 = 12345; + $key1 = 67890; + + $instance1 = new SipHash24($key0, $key1); + $instance2 = new SipHash24($key0, $key1); + + $hash1 = $instance1->hashBytes($data); + $hash2 = $instance2->hashBytes($data); + + $this->assertEquals($hash1, $hash2); + } + + /** + * Test case sensitivity + */ + public function testCaseSensitivity() + { + $hashLower = SipHash24::hash("test"); + $hashUpper = SipHash24::hash("TEST"); + $hashMixed = SipHash24::hash("TeSt"); + + $this->assertNotEquals($hashLower, $hashUpper); + $this->assertNotEquals($hashLower, $hashMixed); + $this->assertNotEquals($hashUpper, $hashMixed); + } + + /** + * Test hashing data with exactly 8 bytes (one block) + */ + public function testHashExactlyOneBlock() + { + $data = "12345678"; // Exactly 8 bytes + $this->assertEquals(8, strlen($data)); + + $hash = SipHash24::hash($data); + $this->assertIsInt($hash); + + // Should be deterministic + $hash2 = SipHash24::hash($data); + $this->assertEquals($hash, $hash2); + } + + /** + * Test hashing data with multiple of 8 bytes + */ + public function testHashMultipleBlocks() + { + $data = "1234567812345678"; // 16 bytes = 2 blocks + $this->assertEquals(16, strlen($data)); + + $hash = SipHash24::hash($data); + $this->assertIsInt($hash); + + // Different from single block + $singleBlockHash = SipHash24::hash("12345678"); + $this->assertNotEquals($hash, $singleBlockHash); + } + + /** + * Test hashing data with partial block (not multiple of 8) + */ + public function testHashPartialBlock() + { + $data = "12345"; // 5 bytes (partial block) + $hash = SipHash24::hash($data); + + $this->assertIsInt($hash); + + // Should be different from other lengths + $hash6 = SipHash24::hash("123456"); + $hash7 = SipHash24::hash("1234567"); + $hash8 = SipHash24::hash("12345678"); + + $this->assertNotEquals($hash, $hash6); + $this->assertNotEquals($hash, $hash7); + $this->assertNotEquals($hash, $hash8); + } + + /** + * Test constructor key masking + */ + public function testConstructorKeyMasking() + { + // Keys should be masked to 64 bits + $largeKey = PHP_INT_MAX; + $instance = new SipHash24($largeKey, $largeKey); + + $hash = $instance->hashBytes("test"); + $this->assertIsInt($hash); + } + + /** + * Test static hash method vs instance method consistency + */ + public function testStaticVsInstanceMethod() + { + $data = "consistency test"; + + $staticHash = SipHash24::hash($data); + $instanceHash = (new SipHash24(SipHash24::GUAVA_K0, SipHash24::GUAVA_K1))->hashBytes($data); + + $this->assertEquals($staticHash, $instanceHash); + } + + /** + * Test performance with many iterations + */ + public function testPerformanceWithManyIterations() + { + $start = microtime(true); + + // Reduce iterations for 32-bit PHP (slower due to manual 64-bit arithmetic) + $iterations = (PHP_INT_SIZE >= 8) ? 1000 : 100; + + for ($i = 0; $i < $iterations; $i++) { + SipHash24::hash("test message {$i}"); + } + + $elapsed = microtime(true) - $start; + + // Should complete in reasonable time (< 5 seconds for 1000 on 64-bit, < 1s for 100 on 32-bit) + $maxTime = (PHP_INT_SIZE >= 8) ? 5.0 : 1.0; + $this->assertLessThan($maxTime, $elapsed, "{$iterations} hashes took too long: {$elapsed}s"); + } + + /** + * Test that hash distribution is reasonable (basic avalanche test) + */ + public function testHashDistribution() + { + $hashes = []; + for ($i = 0; $i < 100; $i++) { + $hash = SipHash24::hash("input_{$i}"); + $hashes[] = $hash; + } + + // All hashes should be unique (high probability with good hash function) + $uniqueHashes = array_unique($hashes); + $this->assertCount(100, $uniqueHashes, "Hash function should produce unique outputs for different inputs"); + } +} diff --git a/php/tests/StandardConsumeServiceTest.php b/php/tests/StandardConsumeServiceTest.php new file mode 100644 index 000000000..be6c59498 --- /dev/null +++ b/php/tests/StandardConsumeServiceTest.php @@ -0,0 +1,240 @@ +body = $body; + $this->systemProperties = new FakeSystemProps($receiptHandle, $messageId); + $this->topic = $topic; + } + + public function getSystemProperties(): ?object { return $this->systemProperties; } + public function getBody() { return $this->body; } + public function getTopic(): string { return $this->topic; } + public function getMessageId(): string { return $this->systemProperties?->getMessageId() ?? ''; } + public function getDeliveryAttempt(): int { return 1; } + public function incrementDeliveryAttempt(): void {} + public function isCorrupted(): bool { return false; } + public function getEndpoints(): ?object { return null; } +} + +class FakeSystemProps { + private $receiptHandle; + private $messageId; + + public function __construct($receiptHandle = null, $messageId = null) + { + $this->receiptHandle = $receiptHandle; + $this->messageId = $messageId; + } + + public function getReceiptHandle() { return $this->receiptHandle; } + public function getMessageId() { return $this->messageId; } + public function hasReceiptHandle() { return $this->receiptHandle !== null; } + public function hasMessageId() { return $this->messageId !== null; } +} + +class FakeTopic { + private $name; + public function __construct($name) { $this->name = $name; } + public function getName() { return $this->name; } + public function hasName() { return !empty($this->name); } +} + +/** + * Fake consumer for testing ConsumeService. + */ +class FakeConsumerForConsume extends FakeConsumer{ + private ?\Apache\Rocketmq\V2\MessagingServiceClient $client = null; + + public function __construct(string $clientId = 'test-consumer') + { + parent::__construct('test-client-id'); + } + + public function getGroupResource() + { + $resource = new \Apache\Rocketmq\V2\Resource(); + $resource->setName('test-group'); + return $resource; + } + + public function getClient(): ?\Apache\Rocketmq\V2\MessagingServiceClient { return $this->client; } + public function setClient($client) { $this->client = $client; } +} + +class StandardConsumeServiceTest extends TestCase +{ + public function setUp(): void + { + \Apache\Rocketmq\Logger::close(); + } + + /** + * Test consumeMessage dispatch logic via reflection. + * This mirrors Java's StandardConsumeServiceTest.testDispatch() which is empty + * because the real logic requires full PushConsumer mock setup. + */ + public function testConsumeMessageReturnsSuccess() + { + $fakeConsumer = new FakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('StdConsumeDispatch'); + + $listener = function($msg) { + return ConsumeResult::SUCCESS; + }; + + $service = new StandardConsumeService($logger, $listener, $fakeConsumer); + $msg = new FakeMessageView('test', 'topic'); + + $method = new \ReflectionMethod($service, 'consumeMessage'); + $method->setAccessible(true); + $result = $method->invoke($service, $msg); + + $this->assertEquals( + ConsumeResult::SUCCESS, + $result, + "consumeMessage should return SUCCESS when listener returns SUCCESS" + ); + } + + public function testConsumeMessageReturnsFailure() + { + $fakeConsumer = new FakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('StdConsumeFail'); + + $listener = function($msg) { + return ConsumeResult::FAILURE; + }; + + $service = new StandardConsumeService($logger, $listener, $fakeConsumer); + $msg = new FakeMessageView('test', 'topic'); + + $method = new \ReflectionMethod($service, 'consumeMessage'); + $method->setAccessible(true); + $result = $method->invoke($service, $msg); + + $this->assertEquals( + ConsumeResult::FAILURE, + $result, + "consumeMessage should return FAILURE when listener returns FAILURE" + ); + } + + public function testConsumeMessageCatchesException() + { + $fakeConsumer = new FakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('StdConsumeException'); + + $listener = function($msg) { + throw new \RuntimeException("Test exception"); + }; + + $service = new StandardConsumeService($logger, $listener, $fakeConsumer); + $msg = new FakeMessageView('test', 'topic'); + + $method = new \ReflectionMethod($service, 'consumeMessage'); + $method->setAccessible(true); + $result = $method->invoke($service, $msg); + + $this->assertEquals( + ConsumeResult::FAILURE, + $result, + "consumeMessage should return FAILURE when listener throws exception" + ); + } + + public function testExtractReceiptHandle() + { + $fakeConsumer = new FakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('StdConsumeExtract'); + + $listener = function($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $fakeConsumer); + + $msgWithHandle = new FakeMessageView('body', 'topic', 'receipt-handle-123'); + + $method = new \ReflectionMethod($service, 'extractReceiptHandle'); + $method->setAccessible(true); + $handle = $method->invoke($service, $msgWithHandle); + + $this->assertEquals( + 'receipt-handle-123', + $handle, + "Should extract receipt handle from message" + ); + } + + public function testExtractMessageId() + { + $fakeConsumer = new FakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('StdConsumeExtractId'); + + $listener = function($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $fakeConsumer); + + $msgWithId = new FakeMessageView('body', 'topic', null, 'msg-id-456'); + + $method = new \ReflectionMethod($service, 'extractMessageId'); + $method->setAccessible(true); + $id = $method->invoke($service, $msgWithId); + + $this->assertEquals('msg-id-456', $id, "Should extract message ID"); + } + + public function testExtractTopic() + { + $fakeConsumer = new FakeConsumerForConsume(); + $logger = \Apache\Rocketmq\Logger::getInstance('StdConsumeExtractTopic'); + + $listener = function($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $fakeConsumer); + + $msg = new FakeMessageView('body', 'my-test-topic'); + + $method = new \ReflectionMethod($service, 'extractTopic'); + $method->setAccessible(true); + $topic = $method->invoke($service, $msg); + + $this->assertEquals('my-test-topic', $topic, "Should extract topic name"); + } +} + diff --git a/php/tests/StatusCheckerTest.php b/php/tests/StatusCheckerTest.php new file mode 100644 index 000000000..4eea6ad36 --- /dev/null +++ b/php/tests/StatusCheckerTest.php @@ -0,0 +1,317 @@ +assertEquals(20000, Code::OK, "OK should be 20000"); + $this->assertEquals(30000, Code::MULTIPLE_RESULTS, "MULTIPLE_RESULTS should be 30000"); + } + + /** + * Mirrors Java: testBadRequest - all 400xx codes that map to BadRequestException. + */ + public function testBadRequestCodes() + { + $badRequestCodes = [ + Code::BAD_REQUEST, + Code::ILLEGAL_ACCESS_POINT, + Code::ILLEGAL_TOPIC, + Code::ILLEGAL_CONSUMER_GROUP, + Code::ILLEGAL_LITE_TOPIC, + Code::ILLEGAL_MESSAGE_TAG, + Code::ILLEGAL_MESSAGE_KEY, + Code::ILLEGAL_MESSAGE_GROUP, + Code::ILLEGAL_MESSAGE_PROPERTY_KEY, + Code::INVALID_TRANSACTION_ID, + Code::ILLEGAL_MESSAGE_ID, + Code::ILLEGAL_FILTER_EXPRESSION, + Code::ILLEGAL_INVISIBLE_TIME, + Code::ILLEGAL_DELIVERY_TIME, + Code::INVALID_RECEIPT_HANDLE, + Code::MESSAGE_PROPERTY_CONFLICT_WITH_TYPE, + Code::UNRECOGNIZED_CLIENT_TYPE, + Code::MESSAGE_CORRUPTED, + Code::CLIENT_ID_REQUIRED, + Code::ILLEGAL_POLLING_TIME, + Code::ILLEGAL_OFFSET, + ]; + + foreach ($badRequestCodes as $code) { + $isBadRequest = ($code >= 40000 && $code < 40100); + $this->assertTrue( + $isBadRequest, + "Code {$code} (" . Code::name($code) . ") should be in BadRequest range (40000-40099)" + ); + } + } + + /** + * Mirrors Java: testUnauthorized. + */ + public function testUnauthorizedCode() + { + $this->assertEquals(40100, Code::UNAUTHORIZED, "UNAUTHORIZED should be 40100"); + } + + /** + * Mirrors Java: testPaymentRequired. + */ + public function testPaymentRequiredCode() + { + $this->assertEquals(40200, Code::PAYMENT_REQUIRED, "PAYMENT_REQUIRED should be 40200"); + } + + /** + * Mirrors Java: testForbidden. + */ + public function testForbiddenCode() + { + $this->assertEquals(40300, Code::FORBIDDEN, "FORBIDDEN should be 40300"); + } + + /** + * Mirrors Java: testNotFound - all 404xx codes. + */ + public function testNotFoundCodes() + { + $this->assertEquals(40400, Code::NOT_FOUND, "NOT_FOUND should be 40400"); + $this->assertEquals(40401, Code::MESSAGE_NOT_FOUND, "MESSAGE_NOT_FOUND should be 40401"); + $this->assertEquals(40402, Code::TOPIC_NOT_FOUND, "TOPIC_NOT_FOUND should be 40402"); + $this->assertEquals(40403, Code::CONSUMER_GROUP_NOT_FOUND, "CONSUMER_GROUP_NOT_FOUND should be 40403"); + $this->assertEquals(40404, Code::OFFSET_NOT_FOUND, "OFFSET_NOT_FOUND should be 40404"); + } + + /** + * Mirrors Java: testPayloadTooLarge - all 413xx codes. + */ + public function testPayloadTooLargeCodes() + { + $this->assertEquals(41300, Code::PAYLOAD_TOO_LARGE, "PAYLOAD_TOO_LARGE should be 41300"); + $this->assertEquals(41301, Code::MESSAGE_BODY_TOO_LARGE, "MESSAGE_BODY_TOO_LARGE should be 41301"); + $this->assertEquals(41302, Code::MESSAGE_BODY_EMPTY, "MESSAGE_BODY_EMPTY should be 41302"); + } + + /** + * Mirrors Java: testTooManyRequests - all 429xx codes. + */ + public function testTooManyRequestsCodes() + { + $this->assertEquals(42900, Code::TOO_MANY_REQUESTS, "TOO_MANY_REQUESTS should be 42900"); + $this->assertEquals(42901, Code::LITE_TOPIC_QUOTA_EXCEEDED, "LITE_TOPIC_QUOTA_EXCEEDED should be 42901"); + $this->assertEquals(42902, Code::LITE_SUBSCRIPTION_QUOTA_EXCEEDED, "LITE_SUBSCRIPTION_QUOTA_EXCEEDED should be 42902"); + } + + /** + * Mirrors Java: testRequestHeaderFieldsTooLarge. + */ + public function testRequestHeaderTooLargeCodes() + { + $this->assertEquals(43100, Code::REQUEST_HEADER_FIELDS_TOO_LARGE, "REQUEST_HEADER_FIELDS_TOO_LARGE should be 43100"); + $this->assertEquals(43101, Code::MESSAGE_PROPERTIES_TOO_LARGE, "MESSAGE_PROPERTIES_TOO_LARGE should be 43101"); + } + + /** + * Mirrors Java: testInternalError - all 500xx codes. + */ + public function testInternalErrorCodes() + { + $this->assertEquals(50000, Code::INTERNAL_ERROR, "INTERNAL_ERROR should be 50000"); + $this->assertEquals(50001, Code::INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR should be 50001"); + $this->assertEquals(50002, Code::HA_NOT_AVAILABLE, "HA_NOT_AVAILABLE should be 50002"); + } + + /** + * Mirrors Java: testProxyTimeout - all 504xx codes. + */ + public function testProxyTimeoutCodes() + { + $this->assertEquals(50400, Code::PROXY_TIMEOUT, "PROXY_TIMEOUT should be 50400"); + $this->assertEquals(50401, Code::MASTER_PERSISTENCE_TIMEOUT, "MASTER_PERSISTENCE_TIMEOUT should be 50401"); + $this->assertEquals(50402, Code::SLAVE_PERSISTENCE_TIMEOUT, "SLAVE_PERSISTENCE_TIMEOUT should be 50402"); + } + + /** + * Mirrors Java: testUnsupported - all 505xx codes. + */ + public function testUnsupportedCodes() + { + $this->assertEquals(50500, Code::UNSUPPORTED, "UNSUPPORTED should be 50500"); + $this->assertEquals(50501, Code::VERSION_UNSUPPORTED, "VERSION_UNSUPPORTED should be 50501"); + $this->assertEquals(50502, Code::VERIFY_FIFO_MESSAGE_UNSUPPORTED, "VERIFY_FIFO_MESSAGE_UNSUPPORTED should be 50502"); + } + + /** + * Tests code name() round-trip for all defined codes. + */ + public function testAllCodeNamesAreValid() + { + $allCodes = [ + Code::CODE_UNSPECIFIED, + Code::OK, + Code::MULTIPLE_RESULTS, + Code::BAD_REQUEST, + Code::ILLEGAL_ACCESS_POINT, + Code::ILLEGAL_TOPIC, + Code::UNAUTHORIZED, + Code::PAYMENT_REQUIRED, + Code::FORBIDDEN, + Code::NOT_FOUND, + Code::PAYLOAD_TOO_LARGE, + Code::TOO_MANY_REQUESTS, + Code::REQUEST_HEADER_FIELDS_TOO_LARGE, + Code::INTERNAL_ERROR, + Code::PROXY_TIMEOUT, + Code::UNSUPPORTED, + Code::ILLEGAL_OFFSET, + Code::ILLEGAL_LITE_TOPIC, + Code::PRECONDITION_FAILED, + Code::REQUEST_TIMEOUT, + Code::NOT_IMPLEMENTED, + Code::FAILED_TO_CONSUME_MESSAGE, + ]; + + foreach ($allCodes as $code) { + $name = Code::name($code); + $this->assertTrue( + is_string($name) && strlen($name) > 0, + "Code {$code} should have a non-empty name" + ); + } + } + + /** + * Tests that invalid code value throws exception. + */ + public function testInvalidCodeNameThrows() + { + $this->expectException(\UnexpectedValueException::class); + Code::name(99999); + } + + /** + * Tests status code categorization helper. + * Mirrors Java's StatusChecker exception mapping logic. + */ + public function testStatusCodeCategoryHelper() + { + $tests = [ + [Code::OK, 'success'], + [Code::MULTIPLE_RESULTS, 'success'], + [Code::BAD_REQUEST, 'badRequest'], + [Code::ILLEGAL_TOPIC, 'badRequest'], + [Code::UNAUTHORIZED, 'unauthorized'], + [Code::PAYMENT_REQUIRED, 'paymentRequired'], + [Code::FORBIDDEN, 'forbidden'], + [Code::NOT_FOUND, 'notFound'], + [Code::MESSAGE_NOT_FOUND, 'notFound'], + [Code::PAYLOAD_TOO_LARGE, 'payloadTooLarge'], + [Code::TOO_MANY_REQUESTS, 'tooManyRequests'], + [Code::REQUEST_HEADER_FIELDS_TOO_LARGE, 'requestHeaderTooLarge'], + [Code::INTERNAL_ERROR, 'internalError'], + [Code::INTERNAL_SERVER_ERROR, 'internalError'], + [Code::PROXY_TIMEOUT, 'proxyTimeout'], + [Code::UNSUPPORTED, 'unsupported'], + [Code::VERSION_UNSUPPORTED, 'unsupported'], + ]; + + foreach ($tests as [$code, $expectedCategory]) { + $actualCategory = self::getStatusCodeCategory($code); + $this->assertEquals( + $expectedCategory, + $actualCategory, + "Code " . Code::name($code) . " ({$code}) should be categorized as {$expectedCategory}" + ); + } + } + + /** + * Helper to categorize status codes into exception groups. + * Mirrors Java's StatusChecker.check() logic. + */ + private static function getStatusCodeCategory($code) + { + switch ($code) { + case Code::OK: + case Code::MULTIPLE_RESULTS: + return 'success'; + case Code::UNAUTHORIZED: + return 'unauthorized'; + case Code::PAYMENT_REQUIRED: + return 'paymentRequired'; + case Code::FORBIDDEN: + return 'forbidden'; + case Code::NOT_FOUND: + case Code::MESSAGE_NOT_FOUND: + case Code::TOPIC_NOT_FOUND: + case Code::CONSUMER_GROUP_NOT_FOUND: + case Code::OFFSET_NOT_FOUND: + return 'notFound'; + case Code::PAYLOAD_TOO_LARGE: + case Code::MESSAGE_BODY_TOO_LARGE: + case Code::MESSAGE_BODY_EMPTY: + return 'payloadTooLarge'; + case Code::TOO_MANY_REQUESTS: + case Code::LITE_TOPIC_QUOTA_EXCEEDED: + case Code::LITE_SUBSCRIPTION_QUOTA_EXCEEDED: + return 'tooManyRequests'; + case Code::REQUEST_HEADER_FIELDS_TOO_LARGE: + case Code::MESSAGE_PROPERTIES_TOO_LARGE: + return 'requestHeaderTooLarge'; + case Code::INTERNAL_ERROR: + case Code::INTERNAL_SERVER_ERROR: + case Code::HA_NOT_AVAILABLE: + return 'internalError'; + case Code::PROXY_TIMEOUT: + case Code::MASTER_PERSISTENCE_TIMEOUT: + case Code::SLAVE_PERSISTENCE_TIMEOUT: + return 'proxyTimeout'; + case Code::UNSUPPORTED: + case Code::VERSION_UNSUPPORTED: + case Code::VERIFY_FIFO_MESSAGE_UNSUPPORTED: + return 'unsupported'; + default: + if ($code >= 40000 && $code < 40100) { + return 'badRequest'; + } + return 'unknown'; + } + } +} + diff --git a/php/tests/SubscriptionSettingsTest.php b/php/tests/SubscriptionSettingsTest.php new file mode 100644 index 000000000..ee9f22b95 --- /dev/null +++ b/php/tests/SubscriptionSettingsTest.php @@ -0,0 +1,347 @@ +setName('test-consumer-group'); + $subscription->setGroup($groupResource); + $subscription->setFifo(false); + + // Add subscription entry with TAG filter + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + + $filterExpression = new FilterExpression(); + $filterExpression->setExpression('*'); + $filterExpression->setType(FilterType::TAG); + + $entry = new SubscriptionEntry(); + $entry->setTopic($topicResource); + $entry->setExpression($filterExpression); + $subscription->setSubscriptions([$entry]); + + // Verify + $this->assertTrue( + $subscription->hasGroup(), + "Subscription should have group" + ); + $this->assertEquals( + 'test-consumer-group', + $subscription->getGroup()->getName(), + "Group name should match" + ); + $this->assertFalse( + $subscription->getFifo(), + "Push subscription should not be FIFO by default" + ); + + $entries = $subscription->getSubscriptions(); + $this->assertTrue( + is_iterable($entries) && iterator_count($entries) === 1, + "Should have 1 subscription entry" + ); + $subscription->setSubscriptions([]); // reset + $subscription->setSubscriptions([$entry]); + + $firstEntry = iterator_to_array($subscription->getSubscriptions())[0]; + $this->assertEquals( + FilterType::TAG, + $firstEntry->getExpression()->getType(), + "Expression type should be TAG" + ); + $this->assertEquals( + 'test-topic', + $firstEntry->getTopic()->getName(), + "Topic name should match" + ); + } + + /** + * Mirrors Java: PushSubscriptionSettingsTest.testToProtobufWithSqlExpression + * Tests SQL92 filter expression type. + */ + public function testSettingsWithSqlExpression() + { + $subscription = new Subscription(); + $groupResource = new Resource(); + $groupResource->setName('test-consumer-group'); + $subscription->setGroup($groupResource); + $subscription->setFifo(false); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + + $filterExpression = new FilterExpression(); + $filterExpression->setExpression('(a > 10 AND a < 100) OR (b IS NOT NULL AND b=TRUE)'); + $filterExpression->setType(FilterType::SQL); + + $entry = new SubscriptionEntry(); + $entry->setTopic($topicResource); + $entry->setExpression($filterExpression); + $subscription->setSubscriptions([$entry]); + + $firstEntry = iterator_to_array($subscription->getSubscriptions())[0]; + $this->assertEquals( + FilterType::SQL, + $firstEntry->getExpression()->getType(), + "Expression type should be SQL" + ); + $this->assertEquals( + '(a > 10 AND a < 100) OR (b IS NOT NULL AND b=TRUE)', + $firstEntry->getExpression()->getExpression(), + "SQL expression should match" + ); + } + + /** + * Tests Settings clientType configuration. + * Mirrors Java: verifying settings.getClientType() == ClientType.PUSH_CONSUMER + */ + public function testSettingsClientType() + { + $settings = new Settings(); + $settings->setClientType(ClientType::PUSH_CONSUMER); + + $this->assertEquals( + ClientType::PUSH_CONSUMER, + $settings->getClientType(), + "ClientType should be PUSH_CONSUMER" + ); + } + + /** + * Tests SimpleConsumer Settings with longPollingTimeout. + * Mirrors Java: SimpleSubscriptionSettingsTest.testToProtobuf + */ + public function testSimpleSettingsWithLongPollingTimeout() + { + $subscription = new Subscription(); + $groupResource = new Resource(); + $groupResource->setName('simple-consumer-group'); + $subscription->setGroup($groupResource); + $subscription->setFifo(false); + $subscription->setReceiveBatchSize(32); + + $topicResource = new Resource(); + $topicResource->setName('simple-topic'); + + $filterExpression = new FilterExpression(); + $filterExpression->setExpression('*'); + $filterExpression->setType(FilterType::TAG); + + $entry = new SubscriptionEntry(); + $entry->setTopic($topicResource); + $entry->setExpression($filterExpression); + $subscription->setSubscriptions([$entry]); + + $this->assertEquals( + 'simple-consumer-group', + $subscription->getGroup()->getName(), + "Group name should match" + ); + $this->assertEquals( + 32, + $subscription->getReceiveBatchSize(), + "Receive batch size should be 32" + ); + $this->assertFalse( + $subscription->getFifo(), + "Simple subscription should not be FIFO" + ); + } + + /** + * Tests FIFO subscription Settings. + * Mirrors Java: PushSubscriptionSettingsTest.testSync with fifo=true + */ + public function testFifoSubscriptionSettings() + { + $subscription = new Subscription(); + $groupResource = new Resource(); + $groupResource->setName('fifo-consumer-group'); + $subscription->setGroup($groupResource); + $subscription->setFifo(true); + $subscription->setReceiveBatchSize(1); + + $this->assertTrue( + $subscription->getFifo(), + "FIFO subscription should have fifo=true" + ); + $this->assertEquals( + 1, + $subscription->getReceiveBatchSize(), + "FIFO receive batch size should be 1" + ); + } + + /** + * Tests multiple subscription entries. + */ + public function testMultipleSubscriptionEntries() + { + $subscription = new Subscription(); + $groupResource = new Resource(); + $groupResource->setName('multi-topic-group'); + $subscription->setGroup($groupResource); + + $entries = []; + for ($i = 0; $i < 3; $i++) { + $topicResource = new Resource(); + $topicResource->setName("topic-{$i}"); + + $filterExpression = new FilterExpression(); + $filterExpression->setExpression('*'); + + $entry = new SubscriptionEntry(); + $entry->setTopic($topicResource); + $entry->setExpression($filterExpression); + $entries[] = $entry; + } + $subscription->setSubscriptions($entries); + + $entryList = iterator_to_array($subscription->getSubscriptions()); + $this->assertEquals( + 3, + count($entryList), + "Should have 3 subscription entries" + ); + + for ($i = 0; $i < 3; $i++) { + $this->assertEquals( + "topic-{$i}", + $entryList[$i]->getTopic()->getName(), + "Topic name at index {$i} should match" + ); + } + } + + /** + * Tests Resource name and namespace. + */ + public function testResourceName() + { + $resource = new Resource(); + $resource->setName('test-topic'); + + $this->assertEquals( + 'test-topic', + $resource->getName(), + "Resource name should match" + ); + } + + /** + * Tests FilterExpression with default type (0 = unspecified, defaults to TAG in Java). + */ + public function testFilterExpressionDefaultType() + { + $filterExpression = new FilterExpression(); + $filterExpression->setExpression('*'); + + // Default type is 0 (unspecified) - Java defaults to TAG + $type = $filterExpression->getType(); + $this->assertEquals( + 0, + $type, + "Default filter expression type should be 0 (unspecified)" + ); + + // Explicit TAG type + $tagFilter = new FilterExpression(); + $tagFilter->setExpression('*'); + $tagFilter->setType(FilterType::TAG); + $this->assertEquals( + FilterType::TAG, + $tagFilter->getType(), + "Explicit filter type should be TAG" + ); + } + + /** + * Tests empty subscription list. + */ + public function testEmptySubscriptions() + { + $subscription = new Subscription(); + $groupResource = new Resource(); + $groupResource->setName('empty-group'); + $subscription->setGroup($groupResource); + $subscription->setSubscriptions([]); + + $entries = iterator_to_array($subscription->getSubscriptions()); + $this->assertEquals( + 0, + count($entries), + "Empty subscriptions should have 0 entries" + ); + } + + /** + * Tests Settings with subscription set. + */ + public function testSettingsWithSubscription() + { + $subscription = new Subscription(); + $groupResource = new Resource(); + $groupResource->setName('settings-test-group'); + $subscription->setGroup($groupResource); + + $settings = new Settings(); + $settings->setClientType(ClientType::SIMPLE_CONSUMER); + $settings->setSubscription($subscription); + + $this->assertTrue( + $settings->hasSubscription(), + "Settings should have subscription" + ); + $this->assertEquals( + ClientType::SIMPLE_CONSUMER, + $settings->getClientType(), + "ClientType should be SIMPLE_CONSUMER" + ); + } +} + diff --git a/php/tests/TelemetrySessionTest.php b/php/tests/TelemetrySessionTest.php new file mode 100644 index 000000000..a8bc9ba87 --- /dev/null +++ b/php/tests/TelemetrySessionTest.php @@ -0,0 +1,654 @@ +setAccessible(true); + $method->invoke($session, $command); + } + + /** + * Get a private property value via reflection. + */ + private function getPrivateProperty(TelemetrySession $session, string $property) + { + $ref = new \ReflectionProperty(TelemetrySession::class, $property); + $ref->setAccessible(true); + return $ref->getValue($session); + } + + /** + * Set a private property value via reflection. + */ + private function setPrivateProperty(TelemetrySession $session, string $property, $value): void + { + $ref = new \ReflectionProperty(TelemetrySession::class, $property); + $ref->setAccessible(true); + $ref->setValue($session, $value); + } + + // ======================== + // Settings command dispatch + // ======================== + + /** + * Test that SETTINGS command sets settingsSynced flag. + */ + public function testHandleResponseWithSettings() + { + $session = $this->getSession('settings-client'); + + $settings = new Settings(); + $settings->setClientType(ClientType::PRODUCER); + $command = new TelemetryCommand(); + $command->setSettings($settings); + + $this->invokeHandleResponse($session, $command); + + $this->assertTrue($session->isSettingsSynced(), "Settings command should mark session as synced"); + $this->assertNotNull($session->getServerSettings(), "Server settings should be stored"); + } + + /** + * Test that SETTINGS command triggers onSettingsChange callback. + */ + public function testOnSettingsChangeCallbackInvoked() + { + $session = $this->getSession('callback-settings'); + $received = null; + + $session->setOnSettingsChange(function ($settings) use (&$received) { + $received = $settings; + }); + + $settings = new Settings(); + $settings->setClientType(ClientType::PUSH_CONSUMER); + $command = new TelemetryCommand(); + $command->setSettings($settings); + + $this->invokeHandleResponse($session, $command); + + $this->assertNotNull($received, "onSettingsChange callback should be invoked"); + $this->assertEquals(ClientType::PUSH_CONSUMER, $received->getClientType()); + } + + /** + * Test that SETTINGS callback exception is handled gracefully. + */ + public function testOnSettingsChangeCallbackExceptionHandled() + { + $session = $this->getSession('callback-exception'); + + $session->setOnSettingsChange(function ($settings) { + throw new \RuntimeException("Callback error"); + }); + + $settings = new Settings(); + $command = new TelemetryCommand(); + $command->setSettings($settings); + + // Should not throw + $this->invokeHandleResponse($session, $command); + + $this->assertTrue($session->isSettingsSynced(), "Session should still be synced after callback exception"); + } + + // ======================== + // STATUS command dispatch + // ======================== + + /** + * Test that STATUS command is processed without error. + */ + public function testHandleResponseWithStatus() + { + $session = $this->getSession('status-client'); + + $status = new Status(); + $status->setCode(Code::OK); + $command = new TelemetryCommand(); + $command->setStatus($status); + + // Should not throw + $this->invokeHandleResponse($session, $command); + + $this->assertFalse($session->isSettingsSynced(), "STATUS command should not set settingsSynced"); + } + + // ======================== + // RecoverOrphanedTransaction command + // ======================== + + /** + * Test RecoverOrphanedTransactionCommand dispatch. + */ + public function testOnRecoverOrphanedTransactionCallback() + { + $session = $this->getSession('orphan-tx'); + $receivedCmd = null; + + $session->setOnRecoverOrphanedTransaction(function ($cmd) use (&$receivedCmd) { + $receivedCmd = $cmd; + }); + + $recoverCmd = new RecoverOrphanedTransactionCommand(); + $recoverCmd->setTransactionId('tx-orphan-001'); + $command = new TelemetryCommand(); + $command->setRecoverOrphanedTransactionCommand($recoverCmd); + + $this->invokeHandleResponse($session, $command); + + $this->assertNotNull($receivedCmd, "RecoverOrphanedTransaction callback should be invoked"); + $this->assertEquals('tx-orphan-001', $receivedCmd->getTransactionId()); + } + + /** + * Test RecoverOrphanedTransaction callback exception is handled gracefully. + */ + public function testOnRecoverOrphanedTransactionCallbackException() + { + $session = $this->getSession('orphan-tx-ex'); + + $session->setOnRecoverOrphanedTransaction(function ($cmd) { + throw new \RuntimeException("Recovery failed"); + }); + + $recoverCmd = new RecoverOrphanedTransactionCommand(); + $recoverCmd->setTransactionId('tx-orphan-002'); + $command = new TelemetryCommand(); + $command->setRecoverOrphanedTransactionCommand($recoverCmd); + + // Should not throw + $this->invokeHandleResponse($session, $command); + $this->assertTrue(true, "Exception in callback should be handled gracefully"); + } + + // ======================== + // VerifyMessage command + // ======================== + + /** + * Test VerifyMessageCommand dispatch with response write-back. + */ + public function testOnVerifyMessageCallback() + { + $session = $this->getSession('verify-msg'); + $receivedCmd = null; + + $session->setOnVerifyMessage(function ($cmd) use (&$receivedCmd) { + $receivedCmd = $cmd; + // Return a TelemetryCommand as response + return new TelemetryCommand(); + }); + + $verifyCmd = new VerifyMessageCommand(); + $verifyCmd->setNonce('nonce-001'); + $command = new TelemetryCommand(); + $command->setVerifyMessageCommand($verifyCmd); + + $this->invokeHandleResponse($session, $command); + + $this->assertNotNull($receivedCmd, "VerifyMessage callback should be invoked"); + $this->assertEquals('nonce-001', $receivedCmd->getNonce()); + } + + /** + * Test VerifyMessage callback exception is handled gracefully. + */ + public function testOnVerifyMessageCallbackException() + { + $session = $this->getSession('verify-ex'); + + $session->setOnVerifyMessage(function ($cmd) { + throw new \RuntimeException("Verify failed"); + }); + + $verifyCmd = new VerifyMessageCommand(); + $verifyCmd->setNonce('nonce-002'); + $command = new TelemetryCommand(); + $command->setVerifyMessageCommand($verifyCmd); + + // Should not throw + $this->invokeHandleResponse($session, $command); + $this->assertTrue(true, "Exception in VerifyMessage callback should be handled gracefully"); + } + + // ======================== + // PrintThreadStackTrace command + // ======================== + + /** + * Test PrintThreadStackTraceCommand dispatch. + */ + public function testOnPrintThreadStackTraceCallback() + { + $session = $this->getSession('print-stack'); + $receivedCmd = null; + + $session->setOnPrintThreadStackTrace(function ($cmd) use (&$receivedCmd) { + $receivedCmd = $cmd; + return new TelemetryCommand(); + }); + + $printCmd = new PrintThreadStackTraceCommand(); + $printCmd->setNonce('stack-nonce-001'); + $command = new TelemetryCommand(); + $command->setPrintThreadStackTraceCommand($printCmd); + + $this->invokeHandleResponse($session, $command); + + $this->assertNotNull($receivedCmd, "PrintThreadStackTrace callback should be invoked"); + $this->assertEquals('stack-nonce-001', $receivedCmd->getNonce()); + } + + // ======================== + // ReconnectEndpoints command + // ======================== + + /** + * Test ReconnectEndpointsCommand dispatch. + */ + public function testOnReconnectEndpointsCallback() + { + $session = $this->getSession('reconnect'); + $receivedCmd = null; + + $session->setOnReconnectEndpoints(function ($cmd) use (&$receivedCmd) { + $receivedCmd = $cmd; + }); + + $reconnectCmd = new ReconnectEndpointsCommand(); + $reconnectCmd->setNonce('reconnect-nonce-001'); + $command = new TelemetryCommand(); + $command->setReconnectEndpointsCommand($reconnectCmd); + + $this->invokeHandleResponse($session, $command); + + $this->assertNotNull($receivedCmd, "ReconnectEndpoints callback should be invoked"); + $this->assertEquals('reconnect-nonce-001', $receivedCmd->getNonce()); + } + + // ======================== + // NotifyUnsubscribeLite command + // ======================== + + /** + * Test NotifyUnsubscribeLiteCommand dispatch. + */ + public function testOnNotifyUnsubscribeLiteCallback() + { + $session = $this->getSession('unsubscribe'); + $receivedCmd = null; + + $session->setOnNotifyUnsubscribeLite(function ($cmd) use (&$receivedCmd) { + $receivedCmd = $cmd; + }); + + $notifyCmd = new NotifyUnsubscribeLiteCommand(); + $notifyCmd->setLiteTopic('lite-topic-001'); + $command = new TelemetryCommand(); + $command->setNotifyUnsubscribeLiteCommand($notifyCmd); + + $this->invokeHandleResponse($session, $command); + + $this->assertNotNull($receivedCmd, "NotifyUnsubscribeLite callback should be invoked"); + $this->assertEquals('lite-topic-001', $receivedCmd->getLiteTopic()); + } + + // ======================== + // Unrecognized command + // ======================== + + /** + * Test unrecognized command does not throw. + */ + public function testHandleResponseWithEmptyCommand() + { + $session = $this->getSession('empty-cmd'); + $command = new TelemetryCommand(); + + // Should not throw + $this->invokeHandleResponse($session, $command); + $this->assertTrue(true, "Empty command should be handled gracefully"); + } + + // ======================== + // Singleton pattern + // ======================== + + /** + * Test singleton returns same instance for same key. + */ + public function testSingletonSameKey() + { + $fakeClient = new FakeMessagingClientForSession(); + $s1 = TelemetrySession::getInstance($fakeClient, 'ep-1', 'client-a'); + $s2 = TelemetrySession::getInstance($fakeClient, 'ep-1', 'client-a'); + + $this->assertSame($s1, $s2, "Same key should return same instance"); + } + + /** + * Test singleton returns different instance for different endpoints. + */ + public function testSingletonDifferentEndpoints() + { + $fakeClient = new FakeMessagingClientForSession(); + $s1 = TelemetrySession::getInstance($fakeClient, 'ep-2', 'client-b'); + $s2 = TelemetrySession::getInstance($fakeClient, 'ep-3', 'client-b'); + + $this->assertNotSame($s1, $s2, "Different endpoints should return different instances"); + } + + /** + * Test singleton returns different instance for different clientIds. + */ + public function testSingletonDifferentClientIds() + { + $fakeClient = new FakeMessagingClientForSession(); + $s1 = TelemetrySession::getInstance($fakeClient, 'ep-4', 'client-x'); + $s2 = TelemetrySession::getInstance($fakeClient, 'ep-4', 'client-y'); + + $this->assertNotSame($s1, $s2, "Different clientIds should return different instances"); + } + + /** + * Test singleton returns different instance for different namespaces. + */ + public function testSingletonDifferentNamespaces() + { + $fakeClient = new FakeMessagingClientForSession(); + $s1 = TelemetrySession::getInstance($fakeClient, 'ep-5', 'client-z', null, 'ns-1'); + $s2 = TelemetrySession::getInstance($fakeClient, 'ep-5', 'client-z', null, 'ns-2'); + + $this->assertNotSame($s1, $s2, "Different namespaces should return different instances"); + } + + /** + * Test singleton returns different instance for different credentials. + */ + public function testSingletonDifferentCredentials() + { + $fakeClient = new FakeMessagingClientForSession(); + $cred1 = new SessionCredentials('ak1', 'sk1'); + $cred2 = new SessionCredentials('ak2', 'sk2'); + $s1 = TelemetrySession::getInstance($fakeClient, 'ep-6', 'client-c', $cred1); + $s2 = TelemetrySession::getInstance($fakeClient, 'ep-6', 'client-c', $cred2); + + $this->assertNotSame($s1, $s2, "Different credentials should return different instances"); + } + + // ======================== + // MAX_INSTANCES eviction + // ======================== + + /** + * Test that exceeding MAX_INSTANCES evicts the oldest session. + */ + public function testMaxInstancesEviction() + { + $fakeClient = new FakeMessagingClientForSession(); + + // Create 10 sessions (MAX_INSTANCES = 10) + $sessions = []; + for ($i = 0; $i < 10; $i++) { + $sessions[] = TelemetrySession::getInstance($fakeClient, "evict-ep-{$i}", "evict-client-{$i}"); + } + + // 11th session should evict the oldest + $newSession = TelemetrySession::getInstance($fakeClient, 'evict-ep-new', 'evict-client-new'); + + $this->assertNotNull($newSession, "New session should be created after eviction"); + } + + // ======================== + // Stale session eviction + // ======================== + + /** + * Test that a closed session is evicted and replaced with a new one. + */ + public function testStaleSessionEvicted() + { + $fakeClient = new FakeMessagingClientForSession(); + $session1 = TelemetrySession::getInstance($fakeClient, 'stale-ep', 'stale-client'); + $session1->close(); + + $session2 = TelemetrySession::getInstance($fakeClient, 'stale-ep', 'stale-client'); + + $this->assertNotSame($session1, $session2, "After close, new getInstance should create fresh instance"); + } + + /** + * Test that sessions exceeding TTL are evicted even if stream appears alive. + */ + public function testTtlExpiredSessionEvicted() + { + $fakeClient = new FakeMessagingClientForSession(); + $session1 = TelemetrySession::getInstance($fakeClient, 'ttl-ep', 'ttl-client'); + + // Fake the timestamp to 31 minutes ago (TTL is 30 min) + $ref = new \ReflectionProperty(TelemetrySession::class, 'instanceTimestamps'); + $ref->setAccessible(true); + $timestamps = $ref->getValue(); + foreach ($timestamps as $key => $ts) { + if (str_contains($key, 'ttl-ep')) { + $timestamps[$key] = time() - 1860; // 31 minutes ago + break; + } + } + $ref->setValue(null, $timestamps); + + $session2 = TelemetrySession::getInstance($fakeClient, 'ttl-ep', 'ttl-client'); + + $this->assertNotSame($session1, $session2, "After TTL expires, getInstance should create a fresh instance"); + } + + // ======================== + // ClientId + // ======================== + + /** + * Test getClientId returns the configured client ID. + */ + public function testGetClientId() + { + $session = $this->getSession('my-client-id'); + $this->assertEquals('my-client-id', $session->getClientId()); + } + + /** + * Test getClientId when no clientId is provided throws (uninitialized property). + */ + public function testGetClientIdDefault() + { + $fakeClient = new FakeMessagingClientForSession(); + $session = TelemetrySession::getInstance($fakeClient, 'ep-noclient', null); + $this->expectException(\Error::class); + $session->getClientId(); + } + + // ======================== + // Initial state + // ======================== + + /** + * Test initial session state. + */ + public function testInitialState() + { + $session = $this->getSession('init-state'); + + $this->assertFalse($session->isSettingsSynced(), "Initial settingsSynced should be false"); + $this->assertNull($session->getSettingsError(), "Initial settingsError should be null"); + $this->assertNull($session->getServerSettings(), "Initial serverSettings should be null"); + } + + // ======================== + // writeSync without stream + // ======================== + + /** + * Test writeSync returns false when stream is not initialized. + */ + public function testWriteSyncWithoutStream() + { + $session = $this->getSession('no-stream'); + + $command = new TelemetryCommand(); + $result = $session->writeSync($command); + + $this->assertFalse($result, "writeSync without stream should return false"); + } + + // ======================== + // resetAll + // ======================== + + /** + * Test resetAll clears all instances. + */ + public function testResetAll() + { + $fakeClient = new FakeMessagingClientForSession(); + $s1 = TelemetrySession::getInstance($fakeClient, 'reset-ep-1', 'reset-1'); + $s2 = TelemetrySession::getInstance($fakeClient, 'reset-ep-2', 'reset-2'); + + TelemetrySession::resetAll(); + + // After reset, new getInstance should create fresh instances + $s3 = TelemetrySession::getInstance($fakeClient, 'reset-ep-1', 'reset-1'); + $this->assertNotSame($s1, $s3, "After resetAll, new instance should be created"); + } + + // ======================== + // Multiple callbacks + // ======================== + + /** + * Test registering all callbacks simultaneously. + */ + public function testMultipleCallbacksRegistered() + { + $session = $this->getSession('multi-cb'); + $calls = []; + + $session->setOnSettingsChange(function () use (&$calls) { $calls[] = 'settings'; }); + $session->setOnRecoverOrphanedTransaction(function () use (&$calls) { $calls[] = 'orphan'; }); + $session->setOnVerifyMessage(function () use (&$calls) { $calls[] = 'verify'; }); + $session->setOnPrintThreadStackTrace(function () use (&$calls) { $calls[] = 'stack'; }); + $session->setOnReconnectEndpoints(function () use (&$calls) { $calls[] = 'reconnect'; }); + $session->setOnNotifyUnsubscribeLite(function () use (&$calls) { $calls[] = 'unsubscribe'; }); + + // Fire settings + $cmd = new TelemetryCommand(); + $cmd->setSettings(new Settings()); + $this->invokeHandleResponse($session, $cmd); + + // Fire orphan tx + $cmd2 = new TelemetryCommand(); + $cmd2->setRecoverOrphanedTransactionCommand(new RecoverOrphanedTransactionCommand()); + $this->invokeHandleResponse($session, $cmd2); + + // Fire verify + $cmd3 = new TelemetryCommand(); + $cmd3->setVerifyMessageCommand(new VerifyMessageCommand()); + $this->invokeHandleResponse($session, $cmd3); + + $this->assertContains('settings', $calls, "Settings callback should fire"); + $this->assertContains('orphan', $calls, "Orphan tx callback should fire"); + $this->assertContains('verify', $calls, "Verify callback should fire"); + } +} + +/** + * Fake gRPC client for session tests. + */ +class FakeMessagingClientForSession +{ + public function Telemetry($metadata = []) + { + return new FakeStreamForSession(); + } +} + +/** + * Fake telemetry stream for session tests. + */ +class FakeStreamForSession +{ + public function write($command) { return true; } + public function flush() {} + public function read() { return null; } + public function cancel() {} + public function writesDone() {} + public function getStatus() { return null; } +} diff --git a/php/tests/TlsCredentialsTest.php b/php/tests/TlsCredentialsTest.php new file mode 100644 index 000000000..6dc8a03f2 --- /dev/null +++ b/php/tests/TlsCredentialsTest.php @@ -0,0 +1,260 @@ +assertTrue( + $tls->isInsecure(), + "createInsecure() should return insecure credentials" + ); + } + + public function testCreateInsecureReturnsNullChannelCredentials() + { + $tls = TlsCredentials::createInsecure(); + $creds = $tls->toChannelCredentials(); + + $this->assertNull( + $creds, + "Insecure credentials should return null ChannelCredentials" + ); + } + + public function testCreateDefaultReturnsSecureCredentials() + { + $tls = TlsCredentials::createDefault(); + + $this->assertFalse( + $tls->isInsecure(), + "createDefault() should return secure credentials" + ); + } + + public function testCreateDefaultShouldVerifyPeer() + { + $tls = TlsCredentials::createDefault(); + + $this->assertTrue( + $tls->shouldVerifyPeer(), + "createDefault() should have verifyPeer = true" + ); + } + + public function testCreateInsecureDevShouldNotVerifyPeer() + { + // Expect E_USER_WARNING trigger_error + $warningTriggered = false; + set_error_handler(function($errno, $errstr) use (&$warningTriggered) { + if ($errno === E_USER_WARNING && strpos($errstr, 'SECURITY WARNING') !== false) { + $warningTriggered = true; + return true; // Prevent default error handler + } + return false; + }); + + try { + $tls = TlsCredentials::createInsecureDev(); + + $this->assertFalse( + $tls->shouldVerifyPeer(), + "createInsecureDev() should have verifyPeer = false" + ); + + $this->assertTrue( + $warningTriggered, + "createInsecureDev() should trigger a security warning" + ); + } finally { + restore_error_handler(); + } + } + + public function testInsecureDevIsSecure() + { + // Expect E_USER_WARNING trigger_error + set_error_handler(function($errno, $errstr) { + if ($errno === E_USER_WARNING && strpos($errstr, 'SECURITY WARNING') !== false) { + return true; // Prevent default error handler + } + return false; + }); + + try { + $tls = TlsCredentials::createInsecureDev(); + + $this->assertFalse( + $tls->isInsecure(), + "createInsecureDev() should NOT be insecure (it uses TLS but skips verification)" + ); + } finally { + restore_error_handler(); + } + } + + public function testCreateWithCaSetsCaPath() + { + $tls = TlsCredentials::createWithCa('/tmp/test-ca.pem'); + + $this->assertEquals( + '/tmp/test-ca.pem', + $tls->getCaCertPath(), + "createWithCa should set the CA cert path" + ); + } + + public function testDefaultCaPathIsNull() + { + $tls = TlsCredentials::createDefault(); + + $this->assertNull( + $tls->getCaCertPath(), + "createDefault() should have null CA path (use system CA)" + ); + } + + public function testInsecureCaPathIsNull() + { + $tls = TlsCredentials::createInsecure(); + + $this->assertNull( + $tls->getCaCertPath(), + "Insecure credentials should have null CA path" + ); + } + + public function testCreateMtlsSetsClientCertAndKey() + { + $tls = TlsCredentials::createMtls('/tmp/client.pem', '/tmp/client-key.pem'); + + $this->assertEquals( + '/tmp/client.pem', + $tls->getClientCertPath(), + "createMtls should set client cert path" + ); + + $this->assertEquals( + '/tmp/client-key.pem', + $tls->getClientKeyPath(), + "createMtls should set client key path" + ); + } + + public function testDefaultClientCertPathIsNull() + { + $tls = TlsCredentials::createDefault(); + + $this->assertNull( + $tls->getClientCertPath(), + "createDefault() should have null client cert path" + ); + } + + public function testDefaultClientKeyPathIsNull() + { + $tls = TlsCredentials::createDefault(); + + $this->assertNull( + $tls->getClientKeyPath(), + "createDefault() should have null client key path" + ); + } + + public function testGetChannelArgsDefaultReturnsEmpty() + { + $tls = TlsCredentials::createDefault(); + + $this->assertEmpty( + $tls->getChannelArgs(), + "createDefault() should return empty channel args (no overrides)" + ); + } + + public function testGetChannelArgsInsecureReturnsEmpty() + { + $tls = TlsCredentials::createInsecure(); + + $this->assertEmpty( + $tls->getChannelArgs(), + "createInsecure() should return empty channel args" + ); + } + + public function testGetChannelArgsInsecureDevSetsOverride() + { + set_error_handler(function($errno, $errstr) { + if ($errno === E_USER_WARNING) { + return true; + } + return false; + }); + + try { + $tls = TlsCredentials::createInsecureDev(); + $args = $tls->getChannelArgs(); + + $this->assertArrayHasKey( + 'grpc.ssl_target_name_override', + $args, + "createInsecureDev() should set grpc.ssl_target_name_override" + ); + $this->assertArrayHasKey( + 'grpc.default_authority', + $args, + "createInsecureDev() should set grpc.default_authority" + ); + } finally { + restore_error_handler(); + } + } + + public function testGetChannelArgsWithCaReturnsEmpty() + { + $tls = TlsCredentials::createWithCa('/tmp/test-ca.pem'); + + $this->assertEmpty( + $tls->getChannelArgs(), + "createWithCa() should return empty channel args (full verification)" + ); + } + + public function testGetChannelArgsMtlsReturnsEmpty() + { + $tls = TlsCredentials::createMtls('/tmp/client.pem', '/tmp/client-key.pem'); + + $this->assertEmpty( + $tls->getChannelArgs(), + "createMtls() should return empty channel args (full verification)" + ); + } +} diff --git a/php/tests/TransactionExtendedTest.php b/php/tests/TransactionExtendedTest.php new file mode 100644 index 000000000..91eb80524 --- /dev/null +++ b/php/tests/TransactionExtendedTest.php @@ -0,0 +1,276 @@ +commitCalls[] = [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $topic, + ]; + } + + public function rollbackTransaction(string $messageId, string $transactionId, string $topic, ?Endpoints $endpoints = null): void + { + $this->rollbackCalls[] = [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $topic, + ]; + } +} + +/** + * Extended transaction tests mirroring Java's TransactionImplTest. + * Tests: tryAddExceededMessages, tryAddReceiptNotContained, + * commitWithNoReceipts, rollbackWithNoReceipts. + * + * Note: PHP's Transaction class does not enforce the single-message + * limit or receipt containment checks that Java's TransactionImpl does. + * These tests verify PHP's actual behavior. + */ +class TransactionExtendedTest extends TestCase +{ + /** + * Mirrors Java: testTryAddExceededMessages. + * Java limits a transaction to one message. PHP now enforces this too. + */ + public function testTryAddExceededMessages() + { + $fakeProducer = new FakeProducerForExtended(); + $transaction = new Transaction($fakeProducer); + + $msg1 = $this->buildMessage('topic-1', 'body-1'); + $msg2 = $this->buildMessage('topic-2', 'body-2'); + + $transaction->tryAddMessage($msg1); + + $this->expectException(\InvalidArgumentException::class); + $transaction->tryAddMessage($msg2); + } + + /** + * Mirrors Java: testTryAddReceiptNotContained. + * Java checks that the message was added before adding a receipt. + * PHP now enforces this too. + */ + public function testTryAddReceiptNotContained() + { + $fakeProducer = new FakeProducerForExtended(); + $transaction = new Transaction($fakeProducer); + + $msg = $this->buildMessage('topic-1', 'body-1'); + + $sendResult = [ + 'messageId' => 'msg-id-1', + 'transactionId' => 'tx-id-1', + ]; + + $this->expectException(\InvalidArgumentException::class); + $transaction->tryAddReceipt($msg, $sendResult); + } + + /** + * Mirrors Java: testCommitWithNoReceipts. + * Java throws IllegalStateException. PHP now does too. + */ + public function testCommitWithNoReceipts() + { + $fakeProducer = new FakeProducerForExtended(); + $transaction = new Transaction($fakeProducer); + + // No messages or receipts added + $this->expectException(\RuntimeException::class); + $transaction->commit(); + } + + /** + * Mirrors Java: testRollbackWithNoReceipts. + * Java throws IllegalStateException. PHP now does too. + */ + public function testRollbackWithNoReceipts() + { + $fakeProducer = new FakeProducerForExtended(); + $transaction = new Transaction($fakeProducer); + + // No messages or receipts added + $this->expectException(\RuntimeException::class); + $transaction->rollback(); + } + + /** + * Full transaction flow: add message, add receipt, commit, + * verify all parameters are correct. + */ + public function testCommitMultipleTopics() + { + $fakeProducer = new FakeProducerForExtended(); + $transaction = new Transaction($fakeProducer); + + $msg = $this->buildMessage('order-topic', 'body-0'); + $transaction->tryAddMessage($msg); + $sendResult = [ + 'messageId' => 'msg-id-0', + 'transactionId' => 'tx-id-0', + ]; + $transaction->tryAddReceipt($msg, $sendResult); + + $transaction->commit(); + + $this->assertEquals( + 1, + count($fakeProducer->commitCalls), + "Message should be committed" + ); + + $this->assertEquals( + 'msg-id-0', + $fakeProducer->commitCalls[0]['messageId'], + "Commit should have correct messageId" + ); + $this->assertEquals( + 'tx-id-0', + $fakeProducer->commitCalls[0]['transactionId'], + "Commit should have correct transactionId" + ); + $this->assertEquals( + 'order-topic', + $fakeProducer->commitCalls[0]['topic'], + "Commit should have correct topic" + ); + } + + /** + * Rollback message and verify it is rolled back. + */ + public function testRollbackMultipleMessages() + { + $fakeProducer = new FakeProducerForExtended(); + $transaction = new Transaction($fakeProducer); + + $msg = $this->buildMessage('rollback-topic-0', 'body-0'); + $transaction->tryAddMessage($msg); + $sendResult = [ + 'messageId' => 'rb-msg-id-0', + 'transactionId' => 'rb-tx-id-0', + ]; + $transaction->tryAddReceipt($msg, $sendResult); + + $transaction->rollback(); + + $this->assertEquals( + 1, + count($fakeProducer->rollbackCalls), + "Message should be rolled back" + ); + } + + /** + * Tests that after commit, a second commit throws because + * receipts are cleared after the first commit. + */ + public function testDoubleCommitDoesNothing() + { + $fakeProducer = new FakeProducerForExtended(); + $transaction = new Transaction($fakeProducer); + + $msg = $this->buildMessage('test-topic', 'test body'); + $transaction->tryAddMessage($msg); + $sendResult = [ + 'messageId' => 'msg-id', + 'transactionId' => 'tx-id', + ]; + $transaction->tryAddReceipt($msg, $sendResult); + + $transaction->commit(); + + // Second commit throws because receipts were cleared + $this->expectException(\RuntimeException::class); + $transaction->commit(); + + $this->assertEquals( + 1, + count($fakeProducer->commitCalls), + "Only first commit should have been recorded" + ); + } + + /** + * Tests the addReceipt() alias method. + */ + public function testAddReceiptAlias() + { + $fakeProducer = new FakeProducerForExtended(); + $transaction = new Transaction($fakeProducer); + + $msg = $this->buildMessage('test-topic', 'test body'); + $transaction->tryAddMessage($msg); + $sendResult = [ + 'messageId' => 'alias-msg-id', + 'transactionId' => 'alias-tx-id', + ]; + + // Use the addReceipt alias instead of tryAddReceipt + $transaction->addReceipt($msg, $sendResult); + $transaction->commit(); + + $this->assertEquals( + 1, + count($fakeProducer->commitCalls), + "addReceipt alias should work same as tryAddReceipt" + ); + $this->assertEquals( + 'alias-msg-id', + $fakeProducer->commitCalls[0]['messageId'], + "Alias should record correct messageId" + ); + } + + /** + * Helper to build a Message protobuf object. + */ + private function buildMessage($topic, $body) + { + $message = new Message(); + $message->setBody($body); + $topicResource = new Resource(); + $topicResource->setName($topic); + $message->setTopic($topicResource); + return $message; + } +} + diff --git a/php/tests/TransactionTest.php b/php/tests/TransactionTest.php new file mode 100644 index 000000000..3b9022ab2 --- /dev/null +++ b/php/tests/TransactionTest.php @@ -0,0 +1,310 @@ +commitCalls[] = [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $topic, + 'endpoints' => $endpoints, + ]; + } + + public function rollbackTransaction(string $messageId, string $transactionId, string $topic, ?Endpoints $endpoints = null): void + { + $this->rollbackCalls[] = [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $topic, + 'endpoints' => $endpoints, + ]; + } +} + +class TransactionTest extends TestCase +{ + public function testTryAddMessage() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('test body'); + + $transaction->tryAddMessage($message); + $this->assertTrue(true, "Message should be added to transaction"); + } + + public function testTryAddReceipt() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('test body'); + + $transaction->tryAddMessage($message); + + $sendResult = [ + 'messageId' => 'test-msg-id-1', + 'transactionId' => 'test-tx-id-1', + ]; + + $transaction->tryAddReceipt($message, $sendResult); + $this->assertTrue(true, "Receipt should be recorded"); + } + + public function testCommit() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('test body'); + + $sendResult = [ + 'messageId' => 'test-msg-id-1', + 'transactionId' => 'test-tx-id-1', + ]; + + $transaction->tryAddMessage($message); + $transaction->tryAddReceipt($message, $sendResult); + $transaction->commit(); + + $this->assertEquals( + 1, + count($fakeProducer->commitCalls), + "Commit should be called once" + ); + $this->assertEquals( + 'test-msg-id-1', + $fakeProducer->commitCalls[0]['messageId'], + "Commit should use the correct message ID" + ); + $this->assertEquals( + 'test-tx-id-1', + $fakeProducer->commitCalls[0]['transactionId'], + "Commit should use the correct transaction ID" + ); + } + + public function testRollback() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('test body'); + + $sendResult = [ + 'messageId' => 'test-msg-id-2', + 'transactionId' => 'test-tx-id-2', + ]; + + $transaction->tryAddMessage($message); + $transaction->tryAddReceipt($message, $sendResult); + $transaction->rollback(); + + $this->assertEquals( + 1, + count($fakeProducer->rollbackCalls), + "Rollback should be called once" + ); + $this->assertEquals( + 'test-msg-id-2', + $fakeProducer->rollbackCalls[0]['messageId'], + "Rollback should use the correct message ID" + ); + } + + public function testCommitClearsReceipts() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('test body'); + + $sendResult = ['messageId' => 'msg-1', 'transactionId' => 'tx-1']; + $transaction->tryAddMessage($message); + $transaction->tryAddReceipt($message, $sendResult); + $transaction->commit(); + + // After commit, second commit throws because receipts are cleared + $this->expectException(\RuntimeException::class); + $transaction->commit(); + + $this->assertEquals( + 1, + count($fakeProducer->commitCalls), + "Only one commit should have been recorded" + ); + } + + public function testMultipleMessagesInTransaction() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('body-0'); + + $sendResult = [ + 'messageId' => "msg-id-0", + 'transactionId' => "tx-id-0", + ]; + $transaction->tryAddMessage($message); + $transaction->tryAddReceipt($message, $sendResult); + + $transaction->commit(); + + $this->assertEquals( + 1, + count($fakeProducer->commitCalls), + "Should commit 1 message" + ); + } + + public function testCommitPassesEndpointsToCommitter() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('test body'); + + // Create broker endpoints + $address = new \Apache\Rocketmq\V2\Address(); + $address->setHost('broker-1.example.com'); + $address->setPort(10911); + $endpoints = new Endpoints(); + $endpoints->setAddresses([$address]); + + $transaction->tryAddMessage($message); + $transaction->tryAddReceipt($message, [ + 'messageId' => 'msg-ep-1', + 'transactionId' => 'tx-ep-1', + ], $endpoints); + + $transaction->commit(); + + $this->assertCount(1, $fakeProducer->commitCalls, "Commit should be called once"); + $call = $fakeProducer->commitCalls[0]; + $this->assertNotNull($call['endpoints'], "Endpoints should be passed to committer"); + $this->assertSame($endpoints, $call['endpoints'], "Exact endpoints object should be forwarded"); + } + + public function testRollbackPassesEndpointsToCommitter() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('test body'); + + // Create broker endpoints + $address = new \Apache\Rocketmq\V2\Address(); + $address->setHost('broker-2.example.com'); + $address->setPort(10911); + $endpoints = new Endpoints(); + $endpoints->setAddresses([$address]); + + $transaction->tryAddMessage($message); + $transaction->tryAddReceipt($message, [ + 'messageId' => 'msg-ep-2', + 'transactionId' => 'tx-ep-2', + ], $endpoints); + + $transaction->rollback(); + + $this->assertCount(1, $fakeProducer->rollbackCalls, "Rollback should be called once"); + $call = $fakeProducer->rollbackCalls[0]; + $this->assertNotNull($call['endpoints'], "Endpoints should be passed to committer on rollback"); + $this->assertSame($endpoints, $call['endpoints'], "Exact endpoints object should be forwarded on rollback"); + } + + public function testCommitWithNullEndpoints() + { + $fakeProducer = new FakeProducerForTransaction(); + $transaction = new Transaction($fakeProducer); + + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('test body'); + + $transaction->tryAddMessage($message); + // No endpoints passed + $transaction->tryAddReceipt($message, [ + 'messageId' => 'msg-null-ep', + 'transactionId' => 'tx-null-ep', + ]); + + $transaction->commit(); + + $call = $fakeProducer->commitCalls[0]; + $this->assertNull($call['endpoints'], "Endpoints should be null when not provided"); + } +} + diff --git a/php/tests/TransactionTraitTest.php b/php/tests/TransactionTraitTest.php new file mode 100644 index 000000000..13420fce5 --- /dev/null +++ b/php/tests/TransactionTraitTest.php @@ -0,0 +1,1042 @@ +logger = Logger::getInstance('FakeProducerForTrait'); + $this->routeManager = new FakeRouteManagerForTrait(); + $this->client = new FakeGrpcClientForTrait($this); + $this->telemetrySession = new FakeTelemetrySessionForTrait(); + $this->settings = new FakeProducerSettingsForTrait(); + $this->validator = new \Apache\Rocketmq\MessageValidator(4194304, false); + } + + // ==================== Send Delegation ==================== + + public function validateMessage(Message $message): void + { + // No-op for testing + } + + public function detectMessageType(Message $message, bool $transactional = false): int + { + return 0; // NORMAL + } + + public function wrapTransactionMessageRequest(array $messages, $messageQueue): object + { + return new \stdClass(); + } + + public function sendMessageWithRetry($request, Message $message, array $messageQueue, int $maxAttempts, bool $txEnabled = false): array + { + $this->sendRetryCalls[] = ['txEnabled' => $txEnabled, 'maxAttempts' => $maxAttempts]; + return [ + 'messageId' => 'fake-msg-id-' . uniqid(), + 'transactionId' => 'fake-tx-id-' . uniqid(), + 'recallHandle' => null, + 'endpoints' => $this->sendResultEndpoints, + ]; + } + + // ==================== Infrastructure Delegation ==================== + + protected function getPublishingLoadBalancer(string $topic): object + { + return $this->routeManager->getPublishingLoadBalancer($topic); + } + + protected function getIsolatedBrokerNames(): array + { + return $this->routeManager->getIsolatedBrokerNames(); + } + + protected function getSettingsMaxAttempts(): int + { + return $this->settings->getMaxAttempts(); + } + + protected function getSettingsTlsCredentials(): ?object + { + return $this->settings->getTlsCredentials(); + } + + protected function isSettingsSslEnabled(): bool + { + return $this->settings->isSslEnabled(); + } + + protected function getClientForRpc(): object + { + return $this->client; + } + + protected function getTelemetrySession(): object + { + return $this->telemetrySession; + } + + protected function getLogger(): Logger + { + return $this->logger; + } + + public function getOperationTimeout(string $operation): int + { + return 10_000_000; // 10 seconds in microseconds + } + + public function buildMetadata(int $timeoutMs): array + { + return ['timeout' => $timeoutMs]; + } + + public function executeInterceptors(string $hookPoint, array $args): void + { + $this->interceptorCalls[] = ['hookPoint' => $hookPoint, 'args' => $args]; + } + + // TransactionCommitter interface + public function commitTransaction(string $messageId, string $transactionId, string $topic, ?Endpoints $endpoints = null): void + { + $this->commitCalls[] = [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $topic, + 'endpoints' => $endpoints, + ]; + } + + public function rollbackTransaction(string $messageId, string $transactionId, string $topic, ?Endpoints $endpoints = null): void + { + $this->rollbackCalls[] = [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $topic, + 'endpoints' => $endpoints, + ]; + } + + // Expose private methods via reflection for testing + public function testEndTransaction(string $messageId, string $transactionId, string $topic, int $resolution, ?Endpoints $endpoints = null, int $source = TransactionSource::SOURCE_CLIENT): void + { + $this->endTransactionCalls[] = [ + 'messageId' => $messageId, + 'transactionId' => $transactionId, + 'topic' => $topic, + 'resolution' => $resolution, + 'source' => $source, + ]; + + $method = new \ReflectionMethod(self::class, 'endTransaction'); + $method->setAccessible(true); + $method->invoke($this, $messageId, $transactionId, $topic, $resolution, $endpoints, $source); + } + + public function testHandleOrphanedTransaction(object $command): void + { + $method = new \ReflectionMethod(self::class, 'handleOrphanedTransaction'); + $method->setAccessible(true); + $method->invoke($this, $command); + } + + public function testRegisterTransactionCheckerCallback(): void + { + $method = new \ReflectionMethod(self::class, 'registerTransactionCheckerCallback'); + $method->setAccessible(true); + $method->invoke($this); + } + + private function buildMessage(string $topic, string $body): Message + { + $message = new Message(); + $message->setBody($body); + $topicResource = new Resource(); + $topicResource->setName($topic); + $message->setTopic($topicResource); + return $message; + } +} + +/** + * Fake RouteManager for trait testing. + */ +class FakeRouteManagerForTrait +{ + public function getPublishingLoadBalancer(string $topic): object + { + return new FakeLoadBalancerForTrait(); + } + + public function getIsolatedBrokerNames(): array + { + return []; + } +} + +/** + * Fake PublishingLoadBalancer for trait testing. + */ +class FakeLoadBalancerForTrait +{ + public function takeMessageQueue(array $isolatedBrokerNames, int $maxAttempts): array + { + $queue = new \Apache\Rocketmq\V2\MessageQueue(); + $topic = new Resource(); + $topic->setName('test-topic'); + $queue->setTopic($topic); + $queue->setId(0); + + $broker = new \Apache\Rocketmq\V2\Broker(); + $broker->setName('broker-0'); + $address = new Address(); + $address->setHost('localhost'); + $address->setPort(10911); + $endpoints = new Endpoints(); + $endpoints->setAddresses([$address]); + $broker->setEndpoints($endpoints); + $queue->setBroker($broker); + + return [$queue]; + } + + public function validateMessageTypeAgainstQueue($queue, int $msgType, string $topic): void + { + // No-op + } +} + +/** + * Fake gRPC client for trait testing. + */ +class FakeGrpcClientForTrait +{ + private $producer; + + public function __construct($producer) + { + $this->producer = $producer; + } + + public function EndTransaction($request, $metadata, $callOptions) + { + return new FakeUnaryCallForTrait(); + } +} + +/** + * Fake gRPC unary call. + */ +class FakeUnaryCallForTrait +{ + public function wait(): array + { + $status = new \stdClass(); + $status->code = 0; + $status->details = ''; + + $response = new \Apache\Rocketmq\V2\EndTransactionResponse(); + $respStatus = new \Apache\Rocketmq\V2\Status(); + $respStatus->setCode(\Apache\Rocketmq\V2\Code::OK); + $response->setStatus($respStatus); + + return [$response, $status]; + } +} + +/** + * Fake TelemetrySession for trait testing. + */ +class FakeTelemetrySessionForTrait +{ + public $onRecoverOrphanedTransactionCallback = null; + + public function setOnRecoverOrphanedTransaction(callable $callback): void + { + $this->onRecoverOrphanedTransactionCallback = $callback; + } +} + +/** + * Fake TransactionChecker implementations. + */ +class CommitChecker implements TransactionChecker +{ + public function check(MessageView $messageView): int + { + return TransactionResolution::COMMIT; + } +} + +class RollbackChecker implements TransactionChecker +{ + public function check(MessageView $messageView): int + { + return TransactionResolution::ROLLBACK; + } +} + +class UnspecifiedChecker implements TransactionChecker +{ + public function check(MessageView $messageView): int + { + return TransactionResolution::TRANSACTION_RESOLUTION_UNSPECIFIED; + } +} + +/** + * Fake LocalTransactionExecuter implementations. + */ +class CommitExecuter implements LocalTransactionExecuter +{ + public bool $executed = false; + + public function execute(MessageView $messageView): int + { + $this->executed = true; + return TransactionResolution::COMMIT; + } +} + +class RollbackExecuter implements LocalTransactionExecuter +{ + public bool $executed = false; + + public function execute(MessageView $messageView): int + { + $this->executed = true; + return TransactionResolution::ROLLBACK; + } +} + +/** + * TransactionTrait tests covering the 2PC (two-phase commit) flow. + * + * 2PC flow: + * 1. Client sends half-message to broker (not visible to consumers) + * 2. Client executes local transaction + * 3. Based on result, client commits or rolls back the half-message + * 4. If client crashes, broker sends RecoverOrphanedTransactionCommand + * and TransactionChecker resolves the orphaned transaction + */ +class TransactionTraitTest extends TestCase +{ + public function setUp(): void + { + Logger::close(); + } + + private function buildMessage(string $topic = 'test-topic', string $body = 'test body'): Message + { + $message = new Message(); + $message->setBody($body); + $topicResource = new Resource(); + $topicResource->setName($topic); + $message->setTopic($topicResource); + return $message; + } + + // ======================== + // 2PC Phase 1: Send half-message + // ======================== + + /** + * Test that sendWithTransaction sends a half-message and returns result. + */ + public function testSendWithTransactionReturnsResult() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $result = $producer->sendWithTransaction($message, $transaction); + + $this->assertArrayHasKey('messageId', $result, "Result should contain messageId"); + $this->assertArrayHasKey('transactionId', $result, "Result should contain transactionId"); + } + + /** + * Test that sendWithTransaction adds message to transaction. + */ + public function testSendWithTransactionAddsMessageToTransaction() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $producer->sendWithTransaction($message, $transaction); + + $this->assertCount(1, $transaction->getMessages(), "Transaction should have 1 message"); + } + + /** + * Test that sendWithTransaction adds receipt to transaction. + */ + public function testSendWithTransactionAddsReceipt() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $producer->sendWithTransaction($message, $transaction); + + $this->assertCount(1, $transaction->getReceipts(), "Transaction should have 1 receipt"); + } + + /** + * Test that sendWithTransaction requests the transaction message type on the + * retry path, so a half message is never retried as a normal message. + */ + public function testSendWithTransactionPreservesTransactionTypeOnRetry() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $producer->sendWithTransaction($message, $transaction); + + $this->assertCount(1, $producer->sendRetryCalls); + $this->assertTrue( + $producer->sendRetryCalls[0]['txEnabled'], + "sendMessageWithRetry should be invoked with txEnabled=true for half messages" + ); + } + + /** + * Test that the receipt records the endpoint of the queue that actually + * succeeded, as reported by sendMessageWithRetry. + */ + public function testSendWithTransactionRecordsSuccessfulQueueEndpoints() + { + $producer = new FakeProducerForTrait(); + + $address = new Address(); + $address->setHost('retry-broker'); + $address->setPort(12345); + $successEndpoints = new Endpoints(); + $successEndpoints->setAddresses([$address]); + $producer->sendResultEndpoints = $successEndpoints; + + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $producer->sendWithTransaction($message, $transaction); + + $receipts = $transaction->getReceipts(); + $this->assertCount(1, $receipts); + $this->assertSame( + $successEndpoints, + array_values($receipts)[0]['endpoints'], + "Receipt should track the endpoint of the queue that actually succeeded" + ); + } + + /** + * Test that without endpoints in the send result, the receipt falls back to + * the first candidate queue's endpoint. + */ + public function testSendWithTransactionFallsBackToFirstQueueEndpoints() + { + $producer = new FakeProducerForTrait(); + // sendResultEndpoints stays null -> trait falls back to messageQueue[0] + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $producer->sendWithTransaction($message, $transaction); + + $receipts = $transaction->getReceipts(); + $this->assertCount(1, $receipts); + $endpoints = array_values($receipts)[0]['endpoints']; + $this->assertNotNull($endpoints, "Receipt should fall back to the first candidate queue endpoint"); + $this->assertEquals('localhost', $endpoints->getAddresses()[0]->getHost()); + } + + // ======================== + // 2PC Phase 2: Local transaction execution + commit/rollback + // ======================== + + /** + * Test that executor returning COMMIT triggers transaction commit. + */ + public function testSendWithTransactionAutoCommit() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + $executer = new CommitExecuter(); + + $producer->sendWithTransaction($message, $transaction, $executer); + + $this->assertTrue($executer->executed, "Executer should be called"); + $this->assertTrue($transaction->isCommitted(), "Transaction should be committed"); + $this->assertFalse($transaction->isRolledBack(), "Transaction should not be rolled back"); + } + + /** + * Test that executor returning ROLLBACK triggers transaction rollback. + */ + public function testSendWithTransactionAutoRollback() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + $executer = new RollbackExecuter(); + + $producer->sendWithTransaction($message, $transaction, $executer); + + $this->assertTrue($executer->executed, "Executer should be called"); + $this->assertTrue($transaction->isRolledBack(), "Transaction should be rolled back"); + $this->assertFalse($transaction->isCommitted(), "Transaction should not be committed"); + } + + /** + * Test that without executor, transaction is not auto-resolved. + */ + public function testSendWithTransactionNoAutoResolve() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $producer->sendWithTransaction($message, $transaction); + + $this->assertFalse($transaction->isCommitted(), "Transaction should not be committed without executor"); + $this->assertFalse($transaction->isRolledBack(), "Transaction should not be rolled back without executor"); + } + + // ======================== + // 2PC Phase 3: Manual commit/rollback + // ======================== + + /** + * Test manual commit after sendWithTransaction. + */ + public function testManualCommitAfterSend() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $producer->sendWithTransaction($message, $transaction); + + $this->assertFalse($transaction->isCommitted()); + $transaction->commit(); + $this->assertTrue($transaction->isCommitted(), "Manual commit should succeed"); + } + + /** + * Test manual rollback after sendWithTransaction. + */ + public function testManualRollbackAfterSend() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $producer->sendWithTransaction($message, $transaction); + + $this->assertFalse($transaction->isRolledBack()); + $transaction->rollback(); + $this->assertTrue($transaction->isRolledBack(), "Manual rollback should succeed"); + } + + // ======================== + // beginTransaction + // ======================== + + /** + * Test beginTransaction without checker throws. + */ + public function testBeginTransactionWithoutCheckerThrows() + { + $producer = new FakeProducerForTrait(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/checker/i'); + $producer->beginTransaction(); + } + + /** + * Test beginTransaction with checker returns Transaction. + */ + public function testBeginTransactionWithChecker() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new CommitChecker()); + + $transaction = $producer->beginTransaction(); + + $this->assertInstanceOf(Transaction::class, $transaction); + } + + /** + * Test beginTransaction when producer is not running throws. + */ + public function testBeginTransactionNotRunning() + { + $producer = new FakeProducerForTrait(); + $producer->isRunning = false; + $producer->setTransactionChecker(new CommitChecker()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not running/i'); + $producer->beginTransaction(); + } + + // ======================== + // sendWithTransaction validations + // ======================== + + /** + * Test sendWithTransaction when producer is not running throws. + */ + public function testSendWithTransactionNotRunning() + { + $producer = new FakeProducerForTrait(); + $producer->isRunning = false; + $message = $this->buildMessage(); + $transaction = new Transaction($producer); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not running/i'); + $producer->sendWithTransaction($message, $transaction); + } + + /** + * Test sendWithTransaction rejects message with messageGroup. + */ + public function testSendWithTransactionRejectsMessageGroup() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $sysProps = new SystemProperties(); + $sysProps->setMessageGroup('test-group'); + $message->setSystemProperties($sysProps); + + $transaction = new Transaction($producer); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/messageGroup/i'); + $producer->sendWithTransaction($message, $transaction); + } + + /** + * Test sendWithTransaction rejects message with deliveryTimestamp. + */ + public function testSendWithTransactionRejectsDeliveryTimestamp() + { + $producer = new FakeProducerForTrait(); + $message = $this->buildMessage(); + $sysProps = new SystemProperties(); + $ts = new \Google\Protobuf\Timestamp(); + $ts->setSeconds(time() + 3600); + $sysProps->setDeliveryTimestamp($ts); + $message->setSystemProperties($sysProps); + + $transaction = new Transaction($producer); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/deliveryTimestamp/i'); + $producer->sendWithTransaction($message, $transaction); + } + + // ======================== + // Orphaned transaction recovery (2PC Phase 4) + // ======================== + + /** + * Test handleOrphanedTransaction with CommitChecker triggers commit. + */ + public function testHandleOrphanedTransactionCommit() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new CommitChecker()); + + // Build orphaned transaction command with a message + $message = $this->buildMessage('orphan-topic'); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('orphan-msg-id'); + $message->setSystemProperties($sysProps); + + $cmd = new \Apache\Rocketmq\V2\RecoverOrphanedTransactionCommand(); + $cmd->setTransactionId('orphan-tx-id'); + $cmd->setMessage($message); + + $producer->testHandleOrphanedTransaction($cmd); + + // Verify endTransaction was called by checking interceptor hooks (side effect of endTransaction) + $commitHooks = array_filter($producer->interceptorCalls, fn($c) => $c['hookPoint'] === MessageHookPoints::COMMIT_TRANSACTION); + $this->assertNotEmpty($commitHooks, "COMMIT_TRANSACTION hook should be triggered for orphaned tx recovery"); + $hookArgs = array_values($commitHooks)[0]['args']; + $this->assertEquals('orphan-msg-id', $hookArgs['messageId']); + $this->assertEquals('orphan-tx-id', $hookArgs['transactionId']); + $this->assertEquals('orphan-topic', $hookArgs['topic']); + } + + /** + * Test handleOrphanedTransaction with RollbackChecker triggers rollback. + */ + public function testHandleOrphanedTransactionRollback() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new RollbackChecker()); + + $message = $this->buildMessage('rollback-topic'); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('rb-msg-id'); + $message->setSystemProperties($sysProps); + + $cmd = new \Apache\Rocketmq\V2\RecoverOrphanedTransactionCommand(); + $cmd->setTransactionId('rb-tx-id'); + $cmd->setMessage($message); + + $producer->testHandleOrphanedTransaction($cmd); + + // Verify endTransaction was called via interceptor hooks + $rollbackHooks = array_filter($producer->interceptorCalls, fn($c) => $c['hookPoint'] === MessageHookPoints::ROLLBACK_TRANSACTION); + $this->assertNotEmpty($rollbackHooks, "ROLLBACK_TRANSACTION hook should be triggered for orphaned tx recovery"); + } + + /** + * Test handleOrphanedTransaction with UnspecifiedChecker does not resolve. + */ + public function testHandleOrphanedTransactionUnspecified() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new UnspecifiedChecker()); + + $message = $this->buildMessage('unspec-topic'); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('unspec-msg-id'); + $message->setSystemProperties($sysProps); + + $cmd = new \Apache\Rocketmq\V2\RecoverOrphanedTransactionCommand(); + $cmd->setTransactionId('unspec-tx-id'); + $cmd->setMessage($message); + + $producer->testHandleOrphanedTransaction($cmd); + + // UNSPECIFIED should NOT trigger endTransaction, so no interceptor hooks + $this->assertEmpty($producer->interceptorCalls, "UNSPECIFIED resolution should not trigger endTransaction"); + } + + /** + * Test handleOrphanedTransaction without checker logs warning. + */ + public function testHandleOrphanedTransactionNoChecker() + { + $producer = new FakeProducerForTrait(); + // No checker set + + $cmd = new \Apache\Rocketmq\V2\RecoverOrphanedTransactionCommand(); + + // Should not throw + $producer->testHandleOrphanedTransaction($cmd); + + $this->assertEmpty($producer->interceptorCalls, "No checker should not trigger endTransaction"); + } + + /** + * Test handleOrphanedTransaction with command that has no message. + */ + public function testHandleOrphanedTransactionNoMessage() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new CommitChecker()); + + $cmd = new \Apache\Rocketmq\V2\RecoverOrphanedTransactionCommand(); + // No message set + + $producer->testHandleOrphanedTransaction($cmd); + + $this->assertEmpty($producer->interceptorCalls, "No message should not trigger endTransaction"); + } + + // ======================== + // TransactionChecker registration + // ======================== + + /** + * Test registerTransactionCheckerCallback registers on TelemetrySession. + */ + public function testRegisterTransactionCheckerCallback() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new CommitChecker()); + + $producer->testRegisterTransactionCheckerCallback(); + + $this->assertNotNull( + $producer->telemetrySession->onRecoverOrphanedTransactionCallback, + "Callback should be registered on TelemetrySession" + ); + } + + /** + * Test registerTransactionCheckerCallback without checker does nothing. + */ + public function testRegisterTransactionCheckerCallbackNoChecker() + { + $producer = new FakeProducerForTrait(); + // No checker set + + $producer->testRegisterTransactionCheckerCallback(); + + $this->assertNull( + $producer->telemetrySession->onRecoverOrphanedTransactionCallback, + "No callback should be registered without checker" + ); + } + + // ======================== + // Setter methods + // ======================== + + /** + * Test setTransactionChecker returns self for chaining. + */ + public function testSetTransactionCheckerReturnsSelf() + { + $producer = new FakeProducerForTrait(); + $result = $producer->setTransactionChecker(new CommitChecker()); + + $this->assertSame($producer, $result, "setTransactionChecker should return self"); + } + + /** + * Test setLocalTransactionExecuter returns self for chaining. + */ + public function testSetLocalTransactionExecuterReturnsSelf() + { + $producer = new FakeProducerForTrait(); + $result = $producer->setLocalTransactionExecuter(new CommitExecuter()); + + $this->assertSame($producer, $result, "setLocalTransactionExecuter should return self"); + } + + // ======================== + // Full 2PC flow integration + // ======================== + + /** + * Test complete 2PC flow: send half-message -> execute local tx -> commit. + */ + public function testFullTwoPhaseCommitFlow() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new CommitChecker()); + + // Phase 1: Begin transaction and send half-message + $transaction = $producer->beginTransaction(); + $message = $this->buildMessage('order-topic', 'order-data'); + + // Phase 2: Execute local transaction (auto-commit) + $executer = new CommitExecuter(); + $result = $producer->sendWithTransaction($message, $transaction, $executer); + + // Verify full flow + $this->assertTrue($executer->executed, "Local transaction should be executed"); + $this->assertTrue($transaction->isCommitted(), "Transaction should be committed"); + $this->assertArrayHasKey('messageId', $result, "Result should contain messageId"); + $this->assertArrayHasKey('transactionId', $result, "Result should contain transactionId"); + } + + /** + * Test complete 2PC flow with manual commit. + */ + public function testFullTwoPhaseCommitFlowManual() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new CommitChecker()); + + // Phase 1: Begin transaction and send half-message + $transaction = $producer->beginTransaction(); + $message = $this->buildMessage('payment-topic', 'payment-data'); + + // Send without executor + $result = $producer->sendWithTransaction($message, $transaction); + + // Phase 2: Manual commit + $this->assertFalse($transaction->isCommitted()); + $transaction->commit(); + $this->assertTrue($transaction->isCommitted(), "Manual commit should succeed"); + + // Verify commit was forwarded to committer + $this->assertCount(1, $producer->commitCalls, "commitTransaction should be called once"); + } + + /** + * Test complete 2PC flow with manual rollback. + */ + public function testFullTwoPhaseRollbackFlow() + { + $producer = new FakeProducerForTrait(); + $producer->setTransactionChecker(new CommitChecker()); + + $transaction = $producer->beginTransaction(); + $message = $this->buildMessage('cancel-topic', 'cancel-data'); + + $result = $producer->sendWithTransaction($message, $transaction); + + $this->assertFalse($transaction->isRolledBack()); + $transaction->rollback(); + $this->assertTrue($transaction->isRolledBack(), "Manual rollback should succeed"); + + $this->assertCount(1, $producer->rollbackCalls, "rollbackTransaction should be called once"); + } + + /** + * Test orphaned transaction recovery after client crash simulation. + */ + public function testOrphanedTransactionRecoveryFlow() + { + // Simulate: Producer crashes after sending half-message + // Broker sends RecoverOrphanedTransactionCommand to new producer instance + + $producer = new FakeProducerForTrait(); + $checker = new CommitChecker(); + $producer->setTransactionChecker($checker); + + // Register callback + $producer->testRegisterTransactionCheckerCallback(); + $this->assertNotNull($producer->telemetrySession->onRecoverOrphanedTransactionCallback); + + // Simulate broker sending orphaned transaction command + $message = $this->buildMessage('orphan-recovery-topic'); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('orphan-recovery-msg-id'); + $message->setSystemProperties($sysProps); + + $cmd = new \Apache\Rocketmq\V2\RecoverOrphanedTransactionCommand(); + $cmd->setTransactionId('orphan-recovery-tx-id'); + $cmd->setMessage($message); + + // Invoke the callback (simulating what TelemetrySession would do) + $callback = $producer->telemetrySession->onRecoverOrphanedTransactionCallback; + $callback($cmd); + + // Verify orphaned transaction was resolved via interceptor hooks + $commitHooks = array_filter($producer->interceptorCalls, fn($c) => $c['hookPoint'] === MessageHookPoints::COMMIT_TRANSACTION); + $this->assertNotEmpty($commitHooks, "Orphaned transaction should be resolved with COMMIT"); + $hookArgs = array_values($commitHooks)[0]['args']; + $this->assertEquals('orphan-recovery-msg-id', $hookArgs['messageId']); + $this->assertEquals('orphan-recovery-tx-id', $hookArgs['transactionId']); + } + + // ======================== + // Interceptor integration + // ======================== + + /** + * Test that commit triggers COMMIT_TRANSACTION interceptor hook. + */ + public function testCommitTriggersInterceptor() + { + $producer = new FakeProducerForTrait(); + + $producer->testEndTransaction('msg-1', 'tx-1', 'test-topic', TransactionResolution::COMMIT); + + $commitHooks = array_filter($producer->interceptorCalls, function ($call) { + return $call['hookPoint'] === MessageHookPoints::COMMIT_TRANSACTION; + }); + $this->assertNotEmpty($commitHooks, "COMMIT_TRANSACTION hook should be triggered"); + } + + /** + * Test that rollback triggers ROLLBACK_TRANSACTION interceptor hook. + */ + public function testRollbackTriggersInterceptor() + { + $producer = new FakeProducerForTrait(); + + $producer->testEndTransaction('msg-1', 'tx-1', 'test-topic', TransactionResolution::ROLLBACK); + + $rollbackHooks = array_filter($producer->interceptorCalls, function ($call) { + return $call['hookPoint'] === MessageHookPoints::ROLLBACK_TRANSACTION; + }); + $this->assertNotEmpty($rollbackHooks, "ROLLBACK_TRANSACTION hook should be triggered"); + } +} diff --git a/php/tests/UtilitiesTest.php b/php/tests/UtilitiesTest.php new file mode 100644 index 000000000..92f417db2 --- /dev/null +++ b/php/tests/UtilitiesTest.php @@ -0,0 +1,231 @@ +body, Utilities::ENCODING_ZLIB_STR); + $original = Utilities::decompressBytes($compressed, Utilities::ENCODING_ZLIB); + + $this->assertEquals( + $this->body, + $original, + "ZLIB compress/decompress round-trip should restore original" + ); + } + + public function testCompressDecompressGzip() + { + $compressed = Utilities::compressBytes($this->body, Utilities::ENCODING_GZIP_STR); + $original = Utilities::decompressBytes($compressed, Utilities::ENCODING_GZIP); + + $this->assertEquals( + $this->body, + $original, + "GZIP compress/decompress round-trip should restore original" + ); + } + + public function testCompressDecompressDeflate() + { + $bytes = $this->body; + $compressed = gzdeflate($bytes, 5); + $original = gzinflate($compressed); + + $this->assertEquals( + $this->body, + $original, + "DEFLATE compress/decompress round-trip should restore original" + ); + } + + public function testDecompressCorruptData() + { + $this->assertFalse( + @gzuncompress('this-is-not-valid-compressed-data'), + "Decompressing corrupt data should return false" + ); + } + + public function testCrc32CheckSum() + { + $result = Utilities::crc32CheckSum($this->body); + + $this->assertEquals( + '9EF61F95', + $result, + "CRC32 of 'foobar' should be 9EF61F95" + ); + } + + public function testMd5CheckSum() + { + $result = Utilities::md5CheckSum($this->body); + + $this->assertEquals( + '3858F62230AC3C915F300C664312C63F', + $result, + "MD5 of 'foobar' should be 3858F62230AC3C915F300C664312C63F" + ); + } + + public function testSha1CheckSum() + { + $result = Utilities::sha1CheckSum($this->body); + + $this->assertEquals( + '8843D7F92416211DE9EBB963FF4CE28125932878', + $result, + "SHA1 of 'foobar' should be 8843D7F92416211DE9EBB963FF4CE28125932878" + ); + } + + public function testChecksumCaseInsensitiveComparison() + { + $md5Upper = Utilities::md5CheckSum($this->body); + $md5Lower = strtolower(Utilities::md5CheckSum($this->body)); + + $this->assertTrue( + strcasecmp($md5Upper, $md5Lower) === 0, + "MD5 hex should be case-insensitive equivalent" + ); + } + + public function testDifferentInputsDifferentChecksums() + { + $crc1 = Utilities::crc32CheckSum('hello'); + $crc2 = Utilities::crc32CheckSum('world'); + + $this->assertTrue( + $crc1 !== $crc2, + "Different inputs should produce different CRC32 checksums" + ); + } + + public function testStackTrace() + { + $stackTrace = debug_backtrace(); + $this->assertTrue( + is_array($stackTrace) && count($stackTrace) > 0, + "Stack trace should be a non-empty array" + ); + } + + public function testPhpDescription() + { + $description = PHP_VERSION; + $this->assertTrue( + strlen($description) > 0, + "PHP version description should be non-empty" + ); + } + + public function testMaxIntValue() + { + $this->assertTrue( + PHP_INT_MAX > 1000000000, + "PHP_INT_MAX should support at least 1GB values" + ); + } + + public function testEmptyStringChecksums() + { + $crc32 = Utilities::crc32CheckSum(''); + $md5 = Utilities::md5CheckSum(''); + $sha1 = Utilities::sha1CheckSum(''); + + $this->assertTrue( + $crc32 !== '' && $md5 !== '' && $sha1 !== '', + "Empty string should still produce non-empty checksums" + ); + } + + public function testAutoDetectGzipEncoding() + { + $compressed = Utilities::compressBytes($this->body, Utilities::ENCODING_GZIP_STR); + $decompressed = Utilities::decompressBytes($compressed); + + $this->assertEquals( + $this->body, + $decompressed, + "Auto-detect should recognize GZIP magic bytes" + ); + } + + public function testAutoDetectZlibEncoding() + { + $compressed = Utilities::compressBytes($this->body, Utilities::ENCODING_ZLIB_STR); + $decompressed = Utilities::decompressBytes($compressed); + + $this->assertEquals( + $this->body, + $decompressed, + "Auto-detect should recognize ZLIB magic bytes" + ); + } + + public function testIdentityEncodingReturnsOriginal() + { + $result = Utilities::decompressBytes($this->body, Utilities::ENCODING_IDENTITY); + + $this->assertEquals( + $this->body, + $result, + "IDENTITY encoding should return data unchanged" + ); + } + + public function testUnsupportedEncodingThrows() + { + $caught = false; + try { + Utilities::compressBytes($this->body, 'BROTLI'); + } catch (\InvalidArgumentException $e) { + $caught = true; + } + + $this->assertTrue($caught, "Unsupported encoding should throw InvalidArgumentException"); + } + + public function testEncodeHexString() + { + $binary = "\x00\x0f\xff\xab\xcd"; + $result = Utilities::encodeHexString($binary); + + $this->assertEquals( + '000FFFABCD', + $result, + "encodeHexString should produce uppercase hex" + ); + } +} diff --git a/php/tests/UtilitiesTestNew.php b/php/tests/UtilitiesTestNew.php new file mode 100644 index 000000000..6713e3948 --- /dev/null +++ b/php/tests/UtilitiesTestNew.php @@ -0,0 +1,195 @@ +body, Utilities::ENCODING_GZIP_STR); + $decompressed = Utilities::decompressBytes($compressed, Utilities::ENCODING_GZIP); + + $this->assertEquals( + $this->body, + $decompressed, + "GZIP compress/decompress round-trip should restore original" + ); + } + + public function testCompressDecompressZlibRoundTrip() + { + $compressed = Utilities::compressBytes($this->body, Utilities::ENCODING_ZLIB_STR); + $decompressed = Utilities::decompressBytes($compressed, Utilities::ENCODING_ZLIB); + + $this->assertEquals( + $this->body, + $decompressed, + "ZLIB compress/decompress round-trip should restore original" + ); + } + + public function testAutoDetectGzipEncoding() + { + $compressed = Utilities::compressBytes($this->body, Utilities::ENCODING_GZIP_STR); + $decompressed = Utilities::decompressBytes($compressed); // null encoding = auto-detect + + $this->assertEquals( + $this->body, + $decompressed, + "Auto-detect should recognize GZIP magic bytes" + ); + } + + public function testAutoDetectZlibEncoding() + { + $compressed = Utilities::compressBytes($this->body, Utilities::ENCODING_ZLIB_STR); + $decompressed = Utilities::decompressBytes($compressed); // null encoding = auto-detect + + $this->assertEquals( + $this->body, + $decompressed, + "Auto-detect should recognize ZLIB magic bytes" + ); + } + + public function testIdentityEncodingReturnsOriginal() + { + $result = Utilities::decompressBytes($this->body, Utilities::ENCODING_IDENTITY); + + $this->assertEquals( + $this->body, + $result, + "IDENTITY encoding should return data unchanged" + ); + } + + public function testCompressEmptyString() + { + $compressed = Utilities::compressBytes('', Utilities::ENCODING_GZIP_STR); + $decompressed = Utilities::decompressBytes($compressed, Utilities::ENCODING_GZIP); + + $this->assertEquals( + '', + $decompressed, + "GZIP round-trip with empty string should return empty" + ); + } + + public function testUnsupportedEncodingThrows() + { + $caught = false; + try { + Utilities::compressBytes($this->body, 'BROTLI'); + } catch (\InvalidArgumentException $e) { + $caught = true; + } + + $this->assertTrue($caught, "Unsupported encoding should throw InvalidArgumentException"); + } + + public function testCrc32CheckSum() + { + $result = Utilities::crc32CheckSum($this->body); + + $this->assertEquals( + '9EF61F95', + $result, + "CRC32 of 'foobar' should be 9EF61F95" + ); + } + + public function testMd5CheckSum() + { + $result = Utilities::md5CheckSum($this->body); + + $this->assertEquals( + '3858F62230AC3C915F300C664312C63F', + $result, + "MD5 of 'foobar' should be 3858F62230AC3C915F300C664312C63F" + ); + } + + public function testSha1CheckSum() + { + $result = Utilities::sha1CheckSum($this->body); + + $this->assertEquals( + '8843D7F92416211DE9EBB963FF4CE28125932878', + $result, + "SHA1 of 'foobar' should be 8843D7F92416211DE9EBB963FF4CE28125932878" + ); + } + + public function testEncodeHexString() + { + $binary = "\x00\x0f\xff\xab\xcd"; + $result = Utilities::encodeHexString($binary); + + $this->assertEquals( + '000FFFABCD', + $result, + "encodeHexString should produce uppercase hex" + ); + } + + public function testDifferentInputsDifferentChecksums() + { + $crc1 = Utilities::crc32CheckSum('hello'); + $crc2 = Utilities::crc32CheckSum('world'); + + $this->assertTrue( + $crc1 !== $crc2, + "Different inputs should produce different CRC32 checksums" + ); + } + + public function testEmptyStringChecksums() + { + $crc32 = Utilities::crc32CheckSum(''); + $md5 = Utilities::md5CheckSum(''); + $sha1 = Utilities::sha1CheckSum(''); + + $this->assertTrue( + $crc32 !== '' && $md5 !== '' && $sha1 !== '', + "Empty string should still produce non-empty checksums" + ); + } + + public function testDecompressCorruptDataThrows() + { + $caught = false; + try { + @Utilities::decompressBytes('this-is-not-valid-compressed-data', Utilities::ENCODING_GZIP); + } catch (\RuntimeException $e) { + $caught = true; + } + + $this->assertTrue($caught, "Decompressing corrupt data should throw RuntimeException"); + } +} diff --git a/php/tests/helpers/FakeConsumer.php b/php/tests/helpers/FakeConsumer.php new file mode 100644 index 000000000..b4bde633f --- /dev/null +++ b/php/tests/helpers/FakeConsumer.php @@ -0,0 +1,121 @@ +clientId = $clientId; + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function getTopicResource(string $topic): \Apache\Rocketmq\V2\Resource + { + $r = new \Apache\Rocketmq\V2\Resource(); + $r->setName($topic); + return $r; + } + + public function getNamespace(): string + { + return ''; + } + + public function getGroupResourceWithNamespace(): \Apache\Rocketmq\V2\Resource + { + $r = new \Apache\Rocketmq\V2\Resource(); + $r->setName('test-group'); + return $r; + } + + public function getSessionCredentials(): ?\Apache\Rocketmq\SessionCredentials + { + return null; + } + + public function buildMetadata(?int $timeoutMs = null): array + { + return []; + } + + public function ackMessage(\Apache\Rocketmq\MessageView $messageView): bool + { + $this->ackCalls[] = $messageView; + return true; + } + + public function executeInterceptors(string $hookPoint, array $context): void + { + } + + public function getRetryPolicy(): ?\Apache\Rocketmq\ExponentialBackoffRetryPolicy + { + return null; + } + + public function getConsumeService(): ?\Apache\Rocketmq\ConsumeService + { + return null; + } + + public function nackMessage(\Apache\Rocketmq\MessageView $messageView, int $deliveryAttempt = 1, ?int $invisibleDuration = null): bool + { + $this->nackCalls[] = $messageView; + return true; + } + + public function getAwaitDuration(): int + { + return $this->awaitDuration; + } + + public function getReceiveBatchSize(): int + { + return $this->receiveBatchSize; + } + + public function getCacheMessageCountThresholdPerQueue(): int + { + return $this->countThreshold; + } + + public function getCacheMessageBytesThresholdPerQueue(): int + { + return $this->bytesThreshold; + } + + public function getClient(): ?\Apache\Rocketmq\V2\MessagingServiceClient + { + return null; + } +} diff --git a/php/tests/helpers/GrpcMockHelper.php b/php/tests/helpers/GrpcMockHelper.php new file mode 100644 index 000000000..2175271a3 --- /dev/null +++ b/php/tests/helpers/GrpcMockHelper.php @@ -0,0 +1,211 @@ +getMock( + MessagingServiceClient::class, + $methods, + [], + '', + false + ); + } + + /** + * Configure a unary gRPC call on the mock client. + * + * @param object $mockClient Mock MessagingServiceClient + * @param string $methodName gRPC method name (e.g. 'QueryRoute') + * @param object|null $response Protobuf response message + * @param int $statusCode gRPC status code (0 = OK) + * @param string $statusDetails Status details string + * @return void + */ + public static function mockUnaryCall( + $mockClient, + string $methodName, + $response, + int $statusCode = 0, + string $statusDetails = 'OK' + ): void { + $status = new \stdClass(); + $status->code = $statusCode; + $status->details = $statusDetails; + + $call = new class($response, $status) { + private $response; + private $status; + + public function __construct($response, $status) + { + $this->response = $response; + $this->status = $status; + } + + public function wait(): array + { + return [$this->response, $this->status]; + } + }; + + $mockClient->method($methodName)->willReturn($call); + } + + /** + * Configure a server-streaming gRPC call on the mock client. + * + * @param object $mockClient Mock MessagingServiceClient + * @param string $methodName gRPC method name (e.g. 'ReceiveMessage') + * @param array $responses Array of protobuf response messages to yield + * @return void + */ + public static function mockServerStreamCall( + $mockClient, + string $methodName, + array $responses + ): void { + $call = new class($responses) { + private array $responses; + + public function __construct(array $responses) + { + $this->responses = $responses; + } + + public function responses(): \Generator + { + foreach ($this->responses as $response) { + yield $response; + } + } + }; + + $mockClient->method($methodName)->willReturn($call); + } + + /** + * Configure a bidi-streaming gRPC call on the mock client. + * + * @param object $mockClient Mock MessagingServiceClient + * @param string $methodName gRPC method name + * @param array $readResponses Array of protobuf response messages for read() + * @return void + */ + public static function mockBidiStreamCall( + $mockClient, + string $methodName, + array $readResponses = [] + ): void { + $stream = new class($readResponses) { + private array $written = []; + private array $readResponses; + private int $readIndex = 0; + + public function __construct(array $readResponses) + { + $this->readResponses = $readResponses; + } + + public function write($data, array $options = []): void + { + $this->written[] = $data; + } + + public function read() + { + if ($this->readIndex < count($this->readResponses)) { + return $this->readResponses[$this->readIndex++]; + } + return null; + } + + public function writesDone(): void + { + } + + public function getWritten(): array + { + return $this->written; + } + + public function cancel(): void + { + } + + public function isCancelled(): bool + { + return false; + } + }; + + $mockClient->method($methodName)->willReturn($stream); + } + + /** + * Create a standalone unary call object for use with willReturnOnConsecutiveCalls(). + * + * @param object|null $response Protobuf response message + * @param int $statusCode gRPC status code + * @return object Anonymous object with wait() method + */ + public static function createUnaryCall($response, int $statusCode = 0): object + { + $status = new \stdClass(); + $status->code = $statusCode; + $status->details = $statusCode === 0 ? 'OK' : 'Error'; + + return new class($response, $status) { + private $response; + private $status; + + public function __construct($response, $status) + { + $this->response = $response; + $this->status = $status; + } + + public function wait(): array + { + return [$this->response, $this->status]; + } + }; + } +} diff --git a/php/tests/helpers/IntegrationTestCase.php b/php/tests/helpers/IntegrationTestCase.php new file mode 100644 index 000000000..50e4510e4 --- /dev/null +++ b/php/tests/helpers/IntegrationTestCase.php @@ -0,0 +1,66 @@ +registerMock($endpoints, $mock); + return $mock; + } +} diff --git a/php/tests/helpers/MockGrpcServer.php b/php/tests/helpers/MockGrpcServer.php new file mode 100644 index 000000000..ab619b3d1 --- /dev/null +++ b/php/tests/helpers/MockGrpcServer.php @@ -0,0 +1,149 @@ +mockClient = GrpcMockHelper::createMockClient(); + return $instance; + } + + /** + * Register the mock client with RpcClientManager for the given endpoints. + * + * @param string $endpoints Server endpoint (e.g. 'localhost:8080') + * @return self + */ + public function register(string $endpoints): self + { + RpcClientManager::getInstance()->registerMock($endpoints, $this->mockClient); + self::$registeredEndpoints[] = $endpoints; + return $this; + } + + /** + * Configure a unary gRPC call stub. + * + * @param string $methodName gRPC method name (e.g. 'QueryRoute') + * @param object|null $response Protobuf response message + * @param int $statusCode gRPC status code (0 = OK) + * @param string $statusDetails Status details string + * @return self + */ + public function withUnary(string $methodName, $response, int $statusCode = 0, string $statusDetails = 'OK'): self + { + GrpcMockHelper::mockUnaryCall($this->mockClient, $methodName, $response, $statusCode, $statusDetails); + $this->stubs[$methodName] = 'unary'; + $this->callCounts[$methodName] = 0; + return $this; + } + + /** + * Configure a server-streaming gRPC call stub. + * + * @param string $methodName gRPC method name (e.g. 'ReceiveMessage') + * @param array $responses Array of protobuf response messages to yield + * @return self + */ + public function withStream(string $methodName, array $responses): self + { + GrpcMockHelper::mockServerStreamCall($this->mockClient, $methodName, $responses); + $this->stubs[$methodName] = 'stream'; + $this->callCounts[$methodName] = 0; + return $this; + } + + /** + * Configure a bidirectional-streaming gRPC call stub. + * + * @param string $methodName gRPC method name (e.g. 'Telemetry') + * @param array $readResponses Array of protobuf response messages for read() + * @return self + */ + public function withBidiStream(string $methodName, array $readResponses = []): self + { + GrpcMockHelper::mockBidiStreamCall($this->mockClient, $methodName, $readResponses); + $this->stubs[$methodName] = 'bidi'; + $this->callCounts[$methodName] = 0; + return $this; + } + + /** + * Get the underlying mock MessagingServiceClient. + * + * @return MessagingServiceClient + */ + public function getClient(): MessagingServiceClient + { + return $this->mockClient; + } + + /** + * Get the call count for a specific method. + * + * @param string $methodName gRPC method name + * @return int Number of times the method was called (0 if never stubbed or called) + */ + public function getCallCount(string $methodName): int + { + return $this->callCounts[$methodName] ?? 0; + } + + /** + * Reset all call counts and stubs. + * + * @return self + */ + public function reset(): self + { + $this->callCounts = []; + $this->stubs = []; + return $this; + } + + /** + * Clean up all registered mocks from RpcClientManager. + * + * @return void + */ + public static function cleanup(): void + { + RpcClientManager::reset(); + self::$registeredEndpoints = []; + } +} diff --git a/php/tests/integration/ConsumeServiceIntegrationTest.php b/php/tests/integration/ConsumeServiceIntegrationTest.php new file mode 100644 index 000000000..df9fb69d7 --- /dev/null +++ b/php/tests/integration/ConsumeServiceIntegrationTest.php @@ -0,0 +1,595 @@ +registerMock($endpoints, $mock); + + return new class($endpoints) implements ConsumerInterface { + private string $endpoints; + private string $group = 'test-group'; + private string $namespace = 'test-ns'; + + public function __construct(string $endpoints) + { + $this->endpoints = $endpoints; + } + + public function getClientId(): string + { + return 'test-client-id'; + } + + public function getTopicResource(string $topic): Resource + { + $resource = new Resource(); + $resource->setName($topic); + return $resource; + } + + public function getNamespace(): string + { + return $this->namespace; + } + + public function getGroupResourceWithNamespace(): Resource + { + $resource = new Resource(); + $ns = $this->namespace; + $name = $ns ? $ns . '%' . $this->group : $this->group; + $resource->setName($name); + return $resource; + } + + public function getSessionCredentials(): ?SessionCredentials + { + return null; + } + + public function buildMetadata(?int $timeoutMs = null): array + { + return ['x-rocketmq-client-id' => 'test-client-id']; + } + + public function ackMessage(MessageView $messageView): bool + { + return true; + } + + public function nackMessage(MessageView $messageView, int $deliveryAttempt = 1, ?int $invisibleDuration = null): bool + { + return true; + } + + public function getAwaitDuration(): int + { + return 30; + } + + public function getReceiveBatchSize(): int + { + return 32; + } + + public function getCacheMessageCountThresholdPerQueue(): int + { + return 1000; + } + + public function getCacheMessageBytesThresholdPerQueue(): int + { + return 1048576; + } + + public function executeInterceptors(string $hookPoint, array $context): void + { + // no-op for tests + } + + public function getRetryPolicy(): ?ExponentialBackoffRetryPolicy + { + return null; + } + + public function getConsumeService(): ?ConsumeService + { + return null; + } + }; + } + + /** + * Helper to create a consumer object (legacy alias). + * + * @return ConsumerInterface Consumer with all methods ConsumeService requires + */ + private function createMockConsumer(): ConsumerInterface + { + return $this->createTestConsumer($this->endpoints); + } + + // Test 1: StandardConsumeService construction + public function testStandardConsumeServiceConstruction() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + + $service = new StandardConsumeService($logger, $listener, $consumer); + $this->assertNotNull($service); + } + + // Test 2: FifoConsumeService construction with/without accelerator + public function testFifoConsumeServiceConstruction() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + + $service = new FifoConsumeService($logger, $listener, $consumer, true); + $this->assertNotNull($service); + } + + // Test 3: consumeMessage returns SUCCESS + public function testConsumeMessageSuccess() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumer); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-001'); + $msg->setSystemProperties($sysProps); + + $messageView = new \Apache\Rocketmq\MessageView($msg); + $result = $service->consumeMessage($messageView); + $this->assertEquals(ConsumeResult::SUCCESS, $result); + } + + // Test 4: consumeMessage returns FAILURE + public function testConsumeMessageFailure() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::FAILURE; }; + $service = new StandardConsumeService($logger, $listener, $consumer); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-002'); + $msg->setSystemProperties($sysProps); + + $messageView = new \Apache\Rocketmq\MessageView($msg); + $result = $service->consumeMessage($messageView); + $this->assertEquals(ConsumeResult::FAILURE, $result); + } + + // Test 5: consumeMessage handles ConsumeResultSuspend + public function testConsumeMessageSuspend() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $suspendResult = ConsumeResultSuspend::of(5000); + $listener = function ($msg) use ($suspendResult) { return $suspendResult; }; + $service = new StandardConsumeService($logger, $listener, $consumer); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-003'); + $msg->setSystemProperties($sysProps); + + $messageView = new \Apache\Rocketmq\MessageView($msg); + $result = $service->consumeMessage($messageView); + $this->assertInstanceOf(ConsumeResultSuspend::class, $result); + $this->assertEquals(5000, $result->getSuspendTimeMs()); + } + + // Test 6: consumeMessage catches exceptions and returns FAILURE + public function testConsumeMessageCatchesException() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { throw new \RuntimeException('test error'); }; + $service = new StandardConsumeService($logger, $listener, $consumer); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-004'); + $msg->setSystemProperties($sysProps); + + $messageView = new \Apache\Rocketmq\MessageView($msg); + $result = $service->consumeMessage($messageView); + $this->assertEquals(ConsumeResult::FAILURE, $result); + } + + // Test 7: ackMessage with no receipt handle returns false + public function testAckMessageNoReceiptHandle() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumer); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + // No system properties -> no receipt handle + + $messageView = new \Apache\Rocketmq\MessageView($msg); + $result = $service->ackMessage($messageView); + $this->assertFalse($result); + } + + // Test 8: ackMessage success with mock + public function testAckMessageSuccess() + { + $endpoints = '127.0.0.1:8080'; + $consumerObj = $this->createTestConsumer($endpoints); + + $ackResponse = new AckMessageResponse(); + $ackStatus = new Status(); + $ackStatus->setCode(20000); + $ackResponse->setStatus($ackStatus); + GrpcMockHelper::mockUnaryCall( + RpcClientManager::getInstance()->getClient($endpoints), + 'AckMessage', + $ackResponse, + 0 + ); + + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumerObj); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-ack-001'); + $sysProps->setReceiptHandle('receipt-handle-001'); + $msg->setSystemProperties($sysProps); + + // Set endpoints on messageView for getBrokerClient routing + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8080); + $brokerEndpoints->setAddresses([$address]); + + $messageView = new \Apache\Rocketmq\MessageView($msg, 'receipt-handle-001', $brokerEndpoints); + $result = $service->ackMessage($messageView); + $this->assertTrue($result); + } + + // Test 9: nackMessage with no receipt handle returns false + public function testNackMessageNoReceiptHandle() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumer); + + $msg = new Message(); + $msg->setBody('test'); + + $messageView = new \Apache\Rocketmq\MessageView($msg); + $result = $service->nackMessage($messageView); + $this->assertFalse($result); + } + + // Test 10: forwardToDeadLetterQueue no receipt handle + public function testForwardToDlqNoReceiptHandle() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumer); + + $msg = new Message(); + $msg->setBody('test'); + + $messageView = new \Apache\Rocketmq\MessageView($msg); + $result = $service->forwardToDeadLetterQueue($messageView); + $this->assertFalse($result); + } + + // Test 11: StandardConsumeService consume with empty messages + public function testStandardConsumeServiceConsumeEmpty() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumer); + + // Create a ProcessQueue that has no cached messages + $mq = new MessageQueue(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $mq->setTopic($topicResource); + $mq->setPermission(Permission::READ_WRITE); + $broker = new Broker(); + $broker->setName('broker-1'); + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8080); + $brokerEndpoints->setAddresses([$address]); + $broker->setEndpoints($brokerEndpoints); + $mq->setBroker($broker); + + $pq = new ProcessQueue($consumer, $mq, '*'); + + // Should not throw with empty messages + $service->consume($pq); + $this->assertTrue(true); + } + + // Test 12: FifoConsumeService consume with empty messages + public function testFifoConsumeServiceConsumeEmpty() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new FifoConsumeService($logger, $listener, $consumer, false); + + $mq = new MessageQueue(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $mq->setTopic($topicResource); + $mq->setPermission(Permission::READ_WRITE); + $broker = new Broker(); + $broker->setName('broker-1'); + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8080); + $brokerEndpoints->setAddresses([$address]); + $broker->setEndpoints($brokerEndpoints); + $mq->setBroker($broker); + + $pq = new ProcessQueue($consumer, $mq, '*'); + + // Should not throw with empty messages + $service->consume($pq); + $this->assertTrue(true); + } + + // Test 13: FifoConsumeService with accelerator + public function testFifoConsumeServiceWithAccelerator() + { + $consumer = $this->createMockConsumer(); + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new FifoConsumeService($logger, $listener, $consumer, true); + $this->assertNotNull($service); + } + + // Test 14: ackMessage retry on server error + public function testAckMessageRetryOnServerError() + { + $endpoints = '127.0.0.2:8080'; + $consumerObj = $this->createTestConsumer($endpoints); + + // Response with server error code + $errorResponse = new AckMessageResponse(); + $errorStatus = new Status(); + $errorStatus->setCode(50001); // Internal server error + $errorResponse->setStatus($errorStatus); + + GrpcMockHelper::mockUnaryCall( + RpcClientManager::getInstance()->getClient($endpoints), + 'AckMessage', + $errorResponse, + 0 + ); + + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumerObj); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-retry-001'); + $sysProps->setReceiptHandle('receipt-retry-001'); + $msg->setSystemProperties($sysProps); + + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.2'); + $address->setPort(8080); + $brokerEndpoints->setAddresses([$address]); + + $messageView = new \Apache\Rocketmq\MessageView($msg, 'receipt-retry-001', $brokerEndpoints); + $result = $service->ackMessage($messageView); + // Should exhaust retries and return false + $this->assertFalse($result); + } + + // Test 15: ackMessage gives up on invalid receipt handle (40003) + public function testAckMessageGivesUpOnInvalidReceiptHandle() + { + $endpoints = '127.0.0.3:8080'; + $consumerObj = $this->createTestConsumer($endpoints); + + $errorResponse = new AckMessageResponse(); + $errorStatus = new Status(); + $errorStatus->setCode(40003); // INVALID_RECEIPT_HANDLE + $errorResponse->setStatus($errorStatus); + + GrpcMockHelper::mockUnaryCall( + RpcClientManager::getInstance()->getClient($endpoints), + 'AckMessage', + $errorResponse, + 0 + ); + + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumerObj); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-invalid-001'); + $sysProps->setReceiptHandle('receipt-invalid-001'); + $msg->setSystemProperties($sysProps); + + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.3'); + $address->setPort(8080); + $brokerEndpoints->setAddresses([$address]); + + $messageView = new \Apache\Rocketmq\MessageView($msg, 'receipt-invalid-001', $brokerEndpoints); + $result = $service->ackMessage($messageView); + // Should give up immediately on 40003 + $this->assertFalse($result); + } + + // Test 16: forwardToDeadLetterQueue success with mock + public function testForwardToDlqSuccess() + { + $endpoints = '127.0.0.4:8080'; + $consumerObj = $this->createTestConsumer($endpoints); + + $dlqResponse = new ForwardMessageToDeadLetterQueueResponse(); + $dlqStatus = new Status(); + $dlqStatus->setCode(20000); + $dlqResponse->setStatus($dlqStatus); + GrpcMockHelper::mockUnaryCall( + RpcClientManager::getInstance()->getClient($endpoints), + 'ForwardMessageToDeadLetterQueue', + $dlqResponse, + 0 + ); + + $logger = Logger::getInstance('ConsumeServiceTest'); + $listener = function ($msg) { return ConsumeResult::SUCCESS; }; + $service = new StandardConsumeService($logger, $listener, $consumerObj); + + $msg = new Message(); + $msg->setBody('test'); + $topic = new Resource(); + $topic->setName('test-topic'); + $msg->setTopic($topic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-dlq-001'); + $sysProps->setReceiptHandle('receipt-dlq-001'); + $msg->setSystemProperties($sysProps); + + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.4'); + $address->setPort(8080); + $brokerEndpoints->setAddresses([$address]); + + $messageView = new \Apache\Rocketmq\MessageView($msg, 'receipt-dlq-001', $brokerEndpoints); + $result = $service->forwardToDeadLetterQueue($messageView); + $this->assertTrue($result); + } +} diff --git a/php/tests/integration/HeartbeatIntegrationTest.php b/php/tests/integration/HeartbeatIntegrationTest.php new file mode 100644 index 000000000..764e427ef --- /dev/null +++ b/php/tests/integration/HeartbeatIntegrationTest.php @@ -0,0 +1,104 @@ +createAndRegisterMock($this->endpoints); + + GrpcMockHelper::mockUnaryCall($mock, 'Heartbeat', new HeartbeatResponse(), 0); + GrpcMockHelper::mockUnaryCall($mock, 'NotifyClientTermination', new NotifyClientTerminationResponse(), 0); + + $settingsResponse = new TelemetryCommand(); + $settingsResponse->setSettings(new \Apache\Rocketmq\V2\Settings()); + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', [$settingsResponse]); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + // Call doHeartbeat directly to test the heartbeat logic. + // doHeartbeat() has its own concurrency guard; since heartbeatInProgress + // defaults to false, it will proceed to send a real Heartbeat request. + $consumer->doHeartbeat(); + $this->assertTrue(true); + + $consumer->shutdown(); + } + + public function testHeartbeatConcurrencyGuard() + { + $mock = $this->createAndRegisterMock($this->endpoints); + + GrpcMockHelper::mockUnaryCall($mock, 'NotifyClientTermination', new NotifyClientTerminationResponse(), 0); + + $settingsResponse = new TelemetryCommand(); + $settingsResponse->setSettings(new \Apache\Rocketmq\V2\Settings()); + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', [$settingsResponse]); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + // Force heartbeatInProgress to true via reflection + $ref = new \ReflectionProperty($consumer, 'heartbeatInProgress'); + $ref->setAccessible(true); + $ref->setValue($consumer, true); + + // isHeartbeatInProgress() should reflect the reflection-flag we just set + $this->assertTrue($consumer->isHeartbeatInProgress()); + + $consumer->shutdown(); + } + + public function testOnHeartbeatTickInProgressSkips() + { + $mock = $this->createAndRegisterMock($this->endpoints); + GrpcMockHelper::mockUnaryCall($mock, 'NotifyClientTermination', new NotifyClientTerminationResponse(), 0); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + // Set heartbeat in progress via reflection + $ref = new \ReflectionProperty($consumer, 'heartbeatInProgress'); + $ref->setAccessible(true); + $ref->setValue($consumer, true); + + // onHeartbeatTick should skip the heartbeat if it is already in progress + $consumer->onHeartbeatTick(); + $this->assertTrue(true); + + $consumer->shutdown(); + } +} diff --git a/php/tests/integration/LitePushConsumerIntegrationTest.php b/php/tests/integration/LitePushConsumerIntegrationTest.php new file mode 100644 index 000000000..b433409e6 --- /dev/null +++ b/php/tests/integration/LitePushConsumerIntegrationTest.php @@ -0,0 +1,264 @@ +endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $this->assertNotNull($consumer->getClient()); + } + + public function testConstructorThrowsOnEmptyParentTopic() + { + $this->expectException(\InvalidArgumentException::class); + new LitePushConsumer($this->endpoints, 'test-group', '', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + } + + public function testSubscribeLiteAddsTopic() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $result = $consumer->subscribeLite('lite-topic-1'); + $this->assertSame($consumer, $result); + $this->assertContains('lite-topic-1', $consumer->getLiteTopics()); + } + + public function testSubscribeLiteThrowsOnMaxLength() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'maxLiteTopicSize' => 10, + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('exceeds max length'); + $consumer->subscribeLite(str_repeat('a', 20)); + } + + public function testSubscribeLiteThrowsOnQuotaExceeded() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'liteSubscriptionQuota' => 1, + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $consumer->subscribeLite('topic-1'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('quota exceeded'); + $consumer->subscribeLite('topic-2'); + } + + public function testUnsubscribeLiteRemovesTopic() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $consumer->subscribeLite('lite-topic-1'); + $consumer->subscribeLite('lite-topic-2'); + $this->assertCount(2, $consumer->getLiteTopics()); + + $result = $consumer->unsubscribeLite('lite-topic-1'); + $this->assertSame($consumer, $result); + $this->assertNotContains('lite-topic-1', $consumer->getLiteTopics()); + $this->assertContains('lite-topic-2', $consumer->getLiteTopics()); + } + + public function testGetLiteTopicsInitiallyEmpty() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $this->assertIsArray($consumer->getLiteTopics()); + $this->assertEmpty($consumer->getLiteTopics()); + } + + public function testSetLiteMessageListener() + { + $listener = function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }; + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => $listener, + ]); + $newListener = function ($msg) { + return \Apache\Rocketmq\ConsumeResult::FAILURE; + }; + $consumer->setLiteMessageListener($newListener); + // setLiteMessageListener returns $this for chaining + $this->assertTrue(true); + } + + public function testStartThrowsWithoutLiteTopics() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('no lite topics'); + $consumer->start(); + } + + public function testStartThrowsWithoutMessageListener() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + // no messageListener + ]); + $consumer->subscribeLite('topic-1'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('no lite message listener'); + $consumer->start(); + } + + public function testIsLiteConsumerReturnsTrue() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $this->assertTrue($consumer->isLiteConsumer()); + } + + public function testGetClientId() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'clientId' => 'lite-client-001', + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $this->assertEquals('lite-client-001', $consumer->getClientId()); + } + + public function testHandleUnsubscribeLite() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $consumer->subscribeLite('lite-topic-1'); + $consumer->subscribeLite('lite-topic-2'); + + $consumer->handleUnsubscribeLite('lite-topic-1'); + $this->assertNotContains('lite-topic-1', $consumer->getLiteTopics()); + } + + public function testHandleUnsubscribeLiteIgnoresUnknown() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $consumer->subscribeLite('lite-topic-1'); + + // Should not throw for unknown topic + $consumer->handleUnsubscribeLite('nonexistent'); + $this->assertContains('lite-topic-1', $consumer->getLiteTopics()); + } + + public function testSyncLiteSubscriptionsWithMock() + { + $mock = $this->createAndRegisterMock($this->endpoints); + + $syncResponse = new SyncLiteSubscriptionResponse(); + $syncStatus = new Status(); + $syncStatus->setCode(20000); + $syncResponse->setStatus($syncStatus); + GrpcMockHelper::mockUnaryCall($mock, 'SyncLiteSubscription', $syncResponse, 0); + + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $consumer->subscribeLite('lite-topic-1'); + + // Should not throw + $consumer->syncLiteSubscriptions(); + $this->assertTrue(true); + } + + public function testIsRunningInitiallyFalse() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $this->assertFalse($consumer->isRunning()); + } + + public function testShutdownBeforeStartIsSafe() + { + $consumer = new LitePushConsumer($this->endpoints, 'test-group', 'parent-topic', [ + 'messageListener' => function ($msg) { + return \Apache\Rocketmq\ConsumeResult::SUCCESS; + }, + ]); + $consumer->shutdown(); + $this->assertTrue(true); + } +} diff --git a/php/tests/integration/LoadBalancerIntegrationTest.php b/php/tests/integration/LoadBalancerIntegrationTest.php new file mode 100644 index 000000000..ae456d01a --- /dev/null +++ b/php/tests/integration/LoadBalancerIntegrationTest.php @@ -0,0 +1,145 @@ +setCode(20000); + $routeResponse->setStatus($status); + + $queues = []; + for ($i = 0; $i < $count; $i++) { + $broker = new Broker(); + $broker->setName("broker-{$i}"); + // ID defaults to 0 (MASTER_BROKER_ID), which is what load balancers require + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8080 + $i); + $brokerEndpoints->setAddresses([$address]); + $broker->setEndpoints($brokerEndpoints); + + $mq = new MessageQueue(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $mq->setTopic($topicResource); + $mq->setBroker($broker); + $mq->setPermission(Permission::READ_WRITE); + $queues[] = $mq; + } + $routeResponse->setMessageQueues($queues); + return $routeResponse; + } + + public function testSubscriptionLoadBalancerRoundRobin() + { + $routeResponse = $this->createRouteResponseWithQueues(3); + $lb = new SubscriptionLoadBalancer($routeResponse); + + $mq1 = $lb->takeMessageQueue(); + $mq2 = $lb->takeMessageQueue(); + $mq3 = $lb->takeMessageQueue(); + $mq4 = $lb->takeMessageQueue(); // wraps around + + $this->assertNotNull($mq1); + $this->assertNotNull($mq2); + $this->assertNotNull($mq3); + $this->assertNotNull($mq4); + + // Round-robin: 4th call should return same broker as 1st + $this->assertEquals( + $mq1->getBroker()->getName(), + $mq4->getBroker()->getName() + ); + } + + public function testSubscriptionLoadBalancerSingleQueue() + { + $routeResponse = $this->createRouteResponseWithQueues(1); + $lb = new SubscriptionLoadBalancer($routeResponse); + + $mq1 = $lb->takeMessageQueue(); + $this->assertNotNull($mq1); + + // Same queue every time + $mq2 = $lb->takeMessageQueue(); + $this->assertEquals($mq1->getBroker()->getName(), $mq2->getBroker()->getName()); + } + + public function testPublishingLoadBalancerWithExcludedBrokers() + { + $routeResponse = $this->createRouteResponseWithQueues(2); + $lb = new PublishingLoadBalancer($routeResponse); + + // PublishingLoadBalancer::takeMessageQueue() accepts excluded broker names and count, + // returning an array of MessageQueue objects. + $result = $lb->takeMessageQueue([], 1); + $this->assertCount(1, $result); + $this->assertNotNull($result[0]); + + // Exclude the broker name from the first result so the second call + // must return the other broker. + $excludedBrokerName = $result[0]->getBroker()->getName(); + $result2 = $lb->takeMessageQueue([$excludedBrokerName], 1); + $this->assertCount(1, $result2); + $this->assertNotEquals( + $excludedBrokerName, + $result2[0]->getBroker()->getName() + ); + } + + public function testPublishingLoadBalancerEmptyQueues() + { + $routeResponse = new QueryRouteResponse(); + $status = new V2Status(); + $status->setCode(20000); + $routeResponse->setStatus($status); + $routeResponse->setMessageQueues([]); + + // PublishingLoadBalancer throws when no writable queues are available + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/No writable message queue/'); + new PublishingLoadBalancer($routeResponse); + } +} diff --git a/php/tests/integration/ProcessQueueIntegrationTest.php b/php/tests/integration/ProcessQueueIntegrationTest.php new file mode 100644 index 000000000..c68df1cd8 --- /dev/null +++ b/php/tests/integration/ProcessQueueIntegrationTest.php @@ -0,0 +1,193 @@ +registerMock($this->endpoints, $mock); + + return new class implements ConsumerInterface { + public function getClientId(): string { return 'test-client-id'; } + public function getTopicResource(string $topic): Resource + { + $r = new Resource(); + $r->setName($topic); + return $r; + } + public function getNamespace(): string { return ''; } + public function getGroupResourceWithNamespace(): Resource + { + $r = new Resource(); + $r->setName('test-group'); + return $r; + } + public function getSessionCredentials(): ?SessionCredentials { return null; } + public function buildMetadata(?int $timeoutMs = null): array { return ['x-rocketmq-client-id' => 'test-client-id']; } + public function ackMessage(MessageView $messageView): bool { return true; } + public function nackMessage(MessageView $messageView, int $deliveryAttempt = 1, ?int $invisibleDuration = null): bool { return true; } + public function getAwaitDuration(): int { return 30; } + public function getReceiveBatchSize(): int { return 32; } + public function getCacheMessageCountThresholdPerQueue(): int { return 1000; } + public function getCacheMessageBytesThresholdPerQueue(): int { return 1048576; } + public function executeInterceptors(string $hookPoint, array $context): void {} + public function getRetryPolicy(): ?ExponentialBackoffRetryPolicy { return null; } + public function getConsumeService(): ?ConsumeService { return null; } + }; + } + + private function createMessageQueue(): MessageQueue + { + $mq = new MessageQueue(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $mq->setTopic($topicResource); + $mq->setPermission(Permission::READ_WRITE); + $broker = new Broker(); + $broker->setName('broker-1'); + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8080); + $brokerEndpoints->setAddresses([$address]); + $broker->setEndpoints($brokerEndpoints); + $mq->setBroker($broker); + return $mq; + } + + public function testConstructorCreatesProcessQueue() + { + $consumer = $this->createTestConsumer(); + + $mq = $this->createMessageQueue(); + + $filterExpression = new FilterExpression(); + $filterExpression->setExpression('*'); + $filterExpression->setType(FilterType::TAG); + + $pq = new ProcessQueue($consumer, $mq, '*'); + + $this->assertNotNull($pq); + } + + public function testConstructorWithDefaultFilterExpression() + { + $consumer = $this->createTestConsumer(); + + $mq = $this->createMessageQueue(); + + // Default filterExpression should be '*' + $pq = new ProcessQueue($consumer, $mq); + + $this->assertNotNull($pq); + } + + public function testFetchMessageImmediately() + { + $consumer = $this->createTestConsumer(); + + $mq = $this->createMessageQueue(); + + $pq = new ProcessQueue($consumer, $mq, '*'); + + // fetchMessageImmediately sets a flag, should not throw + $pq->fetchMessageImmediately(); + $this->assertTrue(true); + } + + public function testGetMessageQueue() + { + $consumer = $this->createTestConsumer(); + + $mq = $this->createMessageQueue(); + + $pq = new ProcessQueue($consumer, $mq, '*'); + $returnedMq = $pq->getMessageQueue(); + + $this->assertSame($mq, $returnedMq); + $this->assertEquals('test-topic', $returnedMq->getTopic()->getName()); + } + + public function testCacheMessages() + { + $consumer = $this->createTestConsumer(); + + $mq = $this->createMessageQueue(); + $pq = new ProcessQueue($consumer, $mq, '*'); + + $msg = new Message(); + $msgTopic = new Resource(); + $msgTopic->setName('test-topic'); + $msg->setTopic($msgTopic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-001'); + $sysProps->setReceiptHandle('receipt-001'); + $msg->setSystemProperties($sysProps); + $msg->setBody('Hello World'); + + $pq->testCacheMessages([$msg]); + + $cached = $pq->getCachedMessages(); + $this->assertCount(1, $cached); + } + + public function testDropAndIsDropped() + { + $consumer = $this->createTestConsumer(); + + $mq = $this->createMessageQueue(); + $pq = new ProcessQueue($consumer, $mq, '*'); + + $this->assertFalse($pq->isDropped()); + + $pq->drop(); + $this->assertTrue($pq->isDropped()); + } +} diff --git a/php/tests/integration/ProducerIntegrationTest.php b/php/tests/integration/ProducerIntegrationTest.php new file mode 100644 index 000000000..72d786434 --- /dev/null +++ b/php/tests/integration/ProducerIntegrationTest.php @@ -0,0 +1,336 @@ +brokerKey = $this->brokerHost . ':' . $this->brokerPort; + } + + /** + * Set up common mocks shared across Producer tests. + * + * - QueryRoute on main client: needed for route warm-up during start() with topics + * - Telemetry bidi stream on main client: needed for establishTelemetrySession() + * - Heartbeat on broker client: Producer sends heartbeats to broker endpoints + * - NotifyClientTermination on main client: needed for shutdown() + * - Registers broker mock at 127.0.0.1:8081 for heartbeat calls + * + * @param object $mock Main client mock (localhost:8080) + * @return object Broker mock client + */ + private function setupCommonMocks($mock): object + { + // Setup QueryRoute response with message queues pointing to broker + $routeResponse = new QueryRouteResponse(); + $routeStatus = new V2Status(); + $routeStatus->setCode(20000); + $routeResponse->setStatus($routeStatus); + + $broker = new Broker(); + $broker->setName('broker-1'); + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost($this->brokerHost); + $address->setPort($this->brokerPort); + $brokerEndpoints->setAddresses([$address]); + $broker->setEndpoints($brokerEndpoints); + + $mq = new MessageQueue(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $mq->setTopic($topicResource); + $mq->setBroker($broker); + $mq->setPermission(Permission::READ_WRITE); + $routeResponse->setMessageQueues([$mq]); + + GrpcMockHelper::mockUnaryCall($mock, 'QueryRoute', $routeResponse, 0); + + // Setup Telemetry mock (bidi stream) - server echoes back Settings + $settingsResponse = new TelemetryCommand(); + $settingsResponse->setSettings(new \Apache\Rocketmq\V2\Settings()); + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', [$settingsResponse]); + + // Setup NotifyClientTermination mock for graceful shutdown + GrpcMockHelper::mockUnaryCall($mock, 'NotifyClientTermination', new NotifyClientTerminationResponse(), 0); + + // Register broker mock and set up Heartbeat + // Producer's doHeartbeat() sends heartbeats to broker endpoints + $brokerMock = GrpcMockHelper::createMockClient(); + \Apache\Rocketmq\RpcClientManager::getInstance()->registerMock($this->brokerKey, $brokerMock); + GrpcMockHelper::mockUnaryCall($brokerMock, 'Heartbeat', new HeartbeatResponse(), 0); + + return $brokerMock; + } + + /** + * Set up SendMessage mock on the main client for successful send. + * + * Producer's sendMessageWithRetry() calls $this->client->SendMessage(), + * so the SendMessage mock goes on the main client (not the broker mock). + */ + private function setupSendMessageMock($mock): void + { + $sendResponse = new SendMessageResponse(); + $sendStatus = new V2Status(); + $sendStatus->setCode(20000); + $sendResponse->setStatus($sendStatus); + + $entry = new SendResultEntry(); + $entry->setMessageId('msg-001'); + $entry->setTransactionId('tx-001'); + $entryStatus = new V2Status(); + $entryStatus->setCode(20000); + $entry->setStatus($entryStatus); + $sendResponse->setEntries([$entry]); + + GrpcMockHelper::mockUnaryCall($mock, 'SendMessage', $sendResponse, 0); + } + + /** + * Test successful send of a message through the Producer lifecycle. + */ + public function testSendMessageSuccess() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + $this->setupSendMessageMock($mock); + + $producer = new Producer($this->endpoints, ['topics' => ['test-topic']]); + $producer->start(); + + $msg = new Message(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $msg->setTopic($topicResource); + $msg->setBody('Test Message Body'); + + $result = $producer->send($msg); + $this->assertNotNull($result); + $this->assertArrayHasKey('messageId', $result); + $this->assertEquals('msg-001', $result['messageId']); + + $producer->shutdown(); + } + + /** + * Test that shutdown notifies the server of client termination. + */ + public function testShutdownNotifiesTermination() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $producer = new Producer($this->endpoints, ['topics' => ['test-topic']]); + $producer->start(); + $producer->shutdown(); + + // No exception means shutdown completed successfully + $this->assertTrue(true); + } + + /** + * Test starting a Producer without any pre-configured topics. + */ + public function testStartProducerWithoutTopics() + { + $mock = $this->createAndRegisterMock($this->endpoints); + + // Only need Telemetry mock - no route warm-up, no heartbeat + $settingsResponse = new TelemetryCommand(); + $settingsResponse->setSettings(new \Apache\Rocketmq\V2\Settings()); + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', [$settingsResponse]); + + $producer = new Producer($this->endpoints, []); + $producer->start(); + + // Producer should start successfully even without pre-configured topics + $this->assertTrue(true); + + $producer->shutdown(); + } + + /** + * Test that send() throws an exception when the Producer is not started. + */ + public function testSendThrowsWhenNotStarted() + { + $mock = $this->createAndRegisterMock($this->endpoints); + + $producer = new Producer($this->endpoints, ['topics' => ['test-topic']]); + + $msg = new Message(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $msg->setTopic($topicResource); + $msg->setBody('Test Message Body'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not running/i'); + $producer->send($msg); + } + + /** + * Test that send() throws an exception when message has no topic. + */ + public function testSendThrowsWithoutTopic() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $producer = new Producer($this->endpoints, ['topics' => ['test-topic']]); + $producer->start(); + + $msg = new Message(); + $msg->setBody('Test Message Body'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/topic/i'); + $producer->send($msg); + + $producer->shutdown(); + } + + /** + * Test that send() throws an exception when message has empty body. + */ + public function testSendThrowsWithEmptyBody() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $producer = new Producer($this->endpoints, ['topics' => ['test-topic']]); + $producer->start(); + + $msg = new Message(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $msg->setTopic($topicResource); + $msg->setBody(''); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/body/i'); + $producer->send($msg); + + $producer->shutdown(); + } + + /** + * Test double start is idempotent. + */ + public function testDoubleStartIsIdempotent() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $producer = new Producer($this->endpoints, ['topics' => ['test-topic']]); + $producer->start(); + + // Second start should be a no-op + $producer->start(); + + $this->assertTrue(true); + + $producer->shutdown(); + } + + /** + * Test isRunning reflects the producer state. + */ + public function testIsRunningState() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $producer = new Producer($this->endpoints, ['topics' => ['test-topic']]); + + $this->assertFalse($producer->isRunning()); + + $producer->start(); + $this->assertTrue($producer->isRunning()); + + $producer->shutdown(); + $this->assertFalse($producer->isRunning()); + } + + /** + * Test getClientId returns the configured client ID. + */ + public function testGetClientId() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $producer = new Producer($this->endpoints, [ + 'topics' => ['test-topic'], + 'clientId' => 'my-custom-producer', + ]); + + $this->assertEquals('my-custom-producer', $producer->getClientId()); + } + + /** + * Test double shutdown is idempotent. + */ + public function testDoubleShutdownIsIdempotent() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $producer = new Producer($this->endpoints, ['topics' => ['test-topic']]); + $producer->start(); + $producer->shutdown(); + + // Second shutdown should be a no-op + $producer->shutdown(); + + $this->assertFalse($producer->isRunning()); + } +} diff --git a/php/tests/integration/PushConsumerIntegrationTest.php b/php/tests/integration/PushConsumerIntegrationTest.php new file mode 100644 index 000000000..e0974da53 --- /dev/null +++ b/php/tests/integration/PushConsumerIntegrationTest.php @@ -0,0 +1,105 @@ +endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->assertNotNull($consumer->getClient()); + } + + public function testGetClientId() + { + $consumer = new PushConsumer($this->endpoints, 'test-group', [ + 'clientId' => 'custom-push-client', + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->assertEquals('custom-push-client', $consumer->getClientId()); + } + + public function testIsRunningInitiallyFalse() + { + $consumer = new PushConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->assertFalse($consumer->isRunning()); + } + + public function testShutdownBeforeStartIsSafe() + { + $consumer = new PushConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + // Should not throw + $consumer->shutdown(); + $this->assertTrue(true); + } + + public function testNamespaceConfiguration() + { + $consumer = new PushConsumer($this->endpoints, 'test-group', [ + 'namespace' => 'my-namespace', + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->assertEquals('my-namespace', $consumer->getNamespace()); + } + + public function testGetGroupResource() + { + $consumer = new PushConsumer($this->endpoints, 'my-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $resource = $consumer->getGroupResource(); + $this->assertEquals('my-group', $resource->getName()); + } + + public function testGetGroupResourceWithNamespace() + { + $consumer = new PushConsumer($this->endpoints, 'my-group', [ + 'namespace' => 'my-ns', + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $resource = $consumer->getGroupResourceWithNamespace(); + $this->assertEquals('my-group', $resource->getName()); + $this->assertEquals('my-ns', $resource->getResourceNamespace()); + } +} diff --git a/php/tests/integration/RetryErrorHandlingIntegrationTest.php b/php/tests/integration/RetryErrorHandlingIntegrationTest.php new file mode 100644 index 000000000..6245199d7 --- /dev/null +++ b/php/tests/integration/RetryErrorHandlingIntegrationTest.php @@ -0,0 +1,86 @@ +getNextDelayMs(1); + $delay2 = $policy->getNextDelayMs(2); + $delay3 = $policy->getNextDelayMs(3); + + // With maxAttempts=3, attempt 3 returns 0 (cap hit) + // attempt 1: 1000 * 2^0 = 1000 + // attempt 2: 1000 * 2^1 = 2000 + $this->assertGreaterThan(0, $delay1); + $this->assertGreaterThan(0, $delay2); + // With exponent growth, delay should increase + $this->assertGreaterThan($delay1, $delay2); + // attempt 3 >= maxAttempts, returns 0 + $this->assertEquals(0, $delay3); + } + + public function testExponentialBackoffMaxCap() + { + // maxDelayMs=5000 will cap the exponential growth + $policy = new ExponentialBackoffRetryPolicy(10, 1000, 5000, 2.0); + + // attempt 5: 1000 * 2^4 = 16000, capped at 5000 + $delay = $policy->getNextDelayMs(5); + $this->assertLessThanOrEqual(5000, $delay); + $this->assertGreaterThan(0, $delay); + } + + public function testCustomizedBackoffRetryPolicy() + { + $delays = [100, 500, 2000, 10000]; + $policy = new CustomizedBackoffRetryPolicy(5, $delays); + + // getNextDelayMs returns delays from the configured sequence + $this->assertEquals(100, $policy->getNextDelayMs(1)); + $this->assertEquals(500, $policy->getNextDelayMs(2)); + $this->assertEquals(2000, $policy->getNextDelayMs(3)); + $this->assertEquals(10000, $policy->getNextDelayMs(4)); + } + + public function testCustomizedBackoffExceedsArray() + { + $delays = [100, 500]; + // maxAttempts must be > 5 so attempt 5 doesn't return 0 + $policy = new CustomizedBackoffRetryPolicy(6, $delays); + + // Attempt 5 exceeds the delays array length, so last value is returned + $delay = $policy->getNextDelayMs(5); + $this->assertEquals(500, $delay); + + // Attempt 6 >= maxAttempts, returns 0 + $delay2 = $policy->getNextDelayMs(6); + $this->assertEquals(0, $delay2); + } +} diff --git a/php/tests/integration/RpcClientManagerIntegrationTest.php b/php/tests/integration/RpcClientManagerIntegrationTest.php new file mode 100644 index 000000000..54d407bd2 --- /dev/null +++ b/php/tests/integration/RpcClientManagerIntegrationTest.php @@ -0,0 +1,70 @@ +registerMock($endpoints, $mock); + + $client = RpcClientManager::getInstance()->getClient($endpoints); + + $this->assertSame($mock, $client); + } + + public function testMockNotRegisteredReturnsRealClient() + { + $client = RpcClientManager::getInstance()->getClient('localhost:9999'); + + $this->assertNotNull($client); + } + + public function testClearMocksRemovesAllRegistrations() + { + $mock = GrpcMockHelper::createMockClient(); + RpcClientManager::getInstance()->registerMock('localhost:8080', $mock); + + RpcClientManager::getInstance()->clearMocks(); + + // After clearing, getClient creates a real client (or another mock) + $this->assertTrue(true); // clearMocks doesn't throw + } + + public function testDifferentEndpointsHaveDifferentMocks() + { + $mock1 = GrpcMockHelper::createMockClient(); + $mock2 = GrpcMockHelper::createMockClient(); + + RpcClientManager::getInstance()->registerMock('endpoint1:8080', $mock1); + RpcClientManager::getInstance()->registerMock('endpoint2:8080', $mock2); + + $this->assertSame($mock1, RpcClientManager::getInstance()->getClient('endpoint1:8080')); + $this->assertSame($mock2, RpcClientManager::getInstance()->getClient('endpoint2:8080')); + } +} diff --git a/php/tests/integration/SimpleConsumerIntegrationTest.php b/php/tests/integration/SimpleConsumerIntegrationTest.php new file mode 100644 index 000000000..c9e212228 --- /dev/null +++ b/php/tests/integration/SimpleConsumerIntegrationTest.php @@ -0,0 +1,447 @@ +setCode(20000); + $routeResponse->setStatus($routeStatus); + + $broker = new Broker(); + $broker->setName('broker-1'); + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost($brokerHost); + $address->setPort($brokerPort); + $brokerEndpoints->setAddresses([$address]); + $broker->setEndpoints($brokerEndpoints); + + $mq = new MessageQueue(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $mq->setTopic($topicResource); + $mq->setBroker($broker); + $mq->setPermission(Permission::READ_WRITE); + $routeResponse->setMessageQueues([$mq]); + + GrpcMockHelper::mockUnaryCall($mock, 'QueryRoute', $routeResponse, 0); + + // Setup Telemetry mock (bidi stream) - server echoes back Settings + $settingsResponse = new TelemetryCommand(); + $settingsResponse->setSettings(new \Apache\Rocketmq\V2\Settings()); + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', [$settingsResponse]); + + // Setup Heartbeat mock + GrpcMockHelper::mockUnaryCall($mock, 'Heartbeat', new HeartbeatResponse(), 0); + + // Setup NotifyClientTermination mock for graceful shutdown + GrpcMockHelper::mockUnaryCall($mock, 'NotifyClientTermination', new NotifyClientTerminationResponse(), 0); + + // Register broker mock + $brokerMock = GrpcMockHelper::createMockClient(); + \Apache\Rocketmq\RpcClientManager::getInstance()->registerMock($brokerKey, $brokerMock); + + return $brokerMock; + } + + /** + * Create and start a SimpleConsumer with test-topic subscription. + */ + private function createStartedConsumer(): SimpleConsumer + { + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + $consumer->start(); + return $consumer; + } + + /** + * Test receive returns messages from broker. + */ + public function testReceiveWithMessages() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $brokerMock = $this->setupCommonMocks($mock); + + // Setup ReceiveMessage response from broker with a message + $msg = new Message(); + $msgTopic = new Resource(); + $msgTopic->setName('test-topic'); + $msg->setTopic($msgTopic); + $sysProps = new SystemProperties(); + $sysProps->setMessageId('msg-001'); + $sysProps->setReceiptHandle('receipt-001'); + $msg->setSystemProperties($sysProps); + $msg->setBody('Hello World'); + + $receiveResponse = new ReceiveMessageResponse(); + $receiveResponse->setMessage($msg); + + GrpcMockHelper::mockServerStreamCall($brokerMock, 'ReceiveMessage', [$receiveResponse]); + + $consumer = $this->createStartedConsumer(); + + $messages = $consumer->receive(10, 30); + + $this->assertCount(1, $messages); + $this->assertEquals('Hello World', $messages[0]->getBody()); + + $consumer->shutdown(); + } + + /** + * Test receive when no messages available. + */ + public function testReceiveEmptyResult() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $brokerMock = $this->setupCommonMocks($mock); + + // Empty ReceiveMessage response with 40404 status (no messages) + $receiveResponse = new ReceiveMessageResponse(); + $receiveStatus = new V2Status(); + $receiveStatus->setCode(40404); + $receiveResponse->setStatus($receiveStatus); + + GrpcMockHelper::mockServerStreamCall($brokerMock, 'ReceiveMessage', [$receiveResponse]); + + $consumer = $this->createStartedConsumer(); + + $messages = $consumer->receive(10, 30); + $this->assertEmpty($messages); + + $consumer->shutdown(); + } + + /** + * Test ack sends correct gRPC call. + */ + public function testAckMessages() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + // Override AckMessage mock with a response that includes entries with success status + $ackResponse = new AckMessageResponse(); + $ackStatus = new V2Status(); + $ackStatus->setCode(20000); + $ackResponse->setStatus($ackStatus); + + // Create an empty entries list so the ack loop exits immediately + // (when entries list is empty in response, ackMessagesForTopic considers all successful) + GrpcMockHelper::mockUnaryCall($mock, 'AckMessage', $ackResponse, 0); + + $consumer = $this->createStartedConsumer(); + + $msg = new Message(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $msg->setTopic($topicResource); + $sysProps = new SystemProperties(); + $sysProps->setReceiptHandle('receipt-001'); + $sysProps->setMessageId('msg-001'); + $msg->setSystemProperties($sysProps); + + // Should not throw + $consumer->ack([$msg]); + $this->assertTrue(true); // Verify ack completes without exception + + $consumer->shutdown(); + } + + /** + * Test changeInvisibleDuration. + */ + public function testChangeInvisibleDuration() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $changeResponse = new ChangeInvisibleDurationResponse(); + $changeResponse->setReceiptHandle('new-receipt-001'); + GrpcMockHelper::mockUnaryCall($mock, 'ChangeInvisibleDuration', $changeResponse, 0); + + $consumer = $this->createStartedConsumer(); + + $msg = new Message(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $msg->setTopic($topicResource); + $sysProps = new SystemProperties(); + $sysProps->setReceiptHandle('receipt-001'); + $sysProps->setMessageId('msg-001'); + $msg->setSystemProperties($sysProps); + + $messageView = new MessageView($msg, 'receipt-001'); + $result = $consumer->changeInvisibleDuration($messageView, 60); + $this->assertTrue($result); + + $consumer->shutdown(); + } + + /** + * Test start fails when TelemetrySession doesn't get a server settings response. + * Startup must not be treated as successful without server-accepted settings. + */ + public function testStartFailsWithoutTelemetryResponse() + { + $mock = $this->createAndRegisterMock($this->endpoints); + + $routeResponse = new QueryRouteResponse(); + $routeStatus = new V2Status(); + $routeStatus->setCode(20000); + $routeResponse->setStatus($routeStatus); + + $broker = new Broker(); + $broker->setName('test-broker'); + $brokerEndpoints = new Endpoints(); + $brokerEndpoints->setScheme(AddressScheme::IPv4); + $address = new Address(); + $address->setHost('127.0.0.1'); + $address->setPort(8081); + $brokerEndpoints->setAddresses([$address]); + $broker->setEndpoints($brokerEndpoints); + + $mq = new MessageQueue(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $mq->setTopic($topicResource); + $mq->setBroker($broker); + $mq->setPermission(Permission::READ_WRITE); + $routeResponse->setMessageQueues([$mq]); + + GrpcMockHelper::mockUnaryCall($mock, 'QueryRoute', $routeResponse, 0); + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', []); + GrpcMockHelper::mockUnaryCall($mock, 'Heartbeat', new HeartbeatResponse(), 0); + GrpcMockHelper::mockUnaryCall($mock, 'NotifyClientTermination', new NotifyClientTerminationResponse(), 0); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Telemetry/'); + $consumer->start(); + } + + /** + * Test shutdown calls NotifyClientTermination and closes session. + */ + public function testShutdownNotifiesTermination() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $consumer = $this->createStartedConsumer(); + $consumer->shutdown(); + + // No exception means shutdown completed successfully + $this->assertTrue(true); + } + + /** + * Test receive throws when consumer not started. + */ + public function testReceiveThrowsWhenNotStarted() + { + $this->createAndRegisterMock($this->endpoints); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not started/i'); + $consumer->receive(10, 30); + } + + /** + * Test ack throws when consumer not started. + */ + public function testAckThrowsWhenNotStarted() + { + $this->createAndRegisterMock($this->endpoints); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not started/i'); + $consumer->ack([]); + } + + /** + * Test changeInvisibleDuration throws when consumer not started. + */ + public function testChangeInvisibleDurationThrowsWhenNotStarted() + { + $this->createAndRegisterMock($this->endpoints); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not started/i'); + + $msg = new Message(); + $consumer->changeInvisibleDuration($msg, 60); + } + + /** + * Test receive throws on invalid maxMessages <= 0. + */ + public function testReceiveThrowsOnInvalidMaxMessages() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $consumer = $this->createStartedConsumer(); + + try { + $consumer->receive(0, 30); + $this->fail('Expected exception not thrown'); + } catch (\InvalidArgumentException $e) { + $this->assertMatchesRegularExpression('/maxMessages/', $e->getMessage()); + } finally { + $consumer->shutdown(); + } + } + + /** + * Test start throws when no subscriptions configured. + */ + public function testStartThrowsWithoutSubscriptions() + { + $this->createAndRegisterMock($this->endpoints); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/No subscriptions/'); + $consumer->start(); + } + + /** + * Test double start is idempotent. + */ + public function testDoubleStartIsIdempotent() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $consumer = $this->createStartedConsumer(); + + // Second start should be a no-op + $consumer->start(); + + // Consumer should still be functional + $this->assertTrue(true); + + $consumer->shutdown(); + } + + /** + * Test getClientId returns the configured client ID. + */ + public function testGetClientId() + { + $this->createAndRegisterMock($this->endpoints); + + $consumer = new SimpleConsumer($this->endpoints, 'test-group', [ + 'subscriptionExpressions' => ['test-topic' => '*'], + 'clientId' => 'my-custom-client', + ]); + + $this->assertEquals('my-custom-client', $consumer->getClientId()); + } + + /** + * Test subscribe and unsubscribe after start. + */ + public function testSubscribeAndUnsubscribe() + { + $mock = $this->createAndRegisterMock($this->endpoints); + $this->setupCommonMocks($mock); + + $consumer = $this->createStartedConsumer(); + + // Add a new subscription + $consumer->subscribe('another-topic', 'tagA'); + + $expressions = $consumer->getSubscriptionExpressions(); + $this->assertArrayHasKey('test-topic', $expressions); + $this->assertArrayHasKey('another-topic', $expressions); + $this->assertEquals('tagA', $expressions['another-topic']); + + // Remove the subscription + $consumer->unsubscribe('another-topic'); + $expressions = $consumer->getSubscriptionExpressions(); + $this->assertArrayNotHasKey('another-topic', $expressions); + + $consumer->shutdown(); + } +} diff --git a/php/tests/integration/TelemetrySessionIntegrationTest.php b/php/tests/integration/TelemetrySessionIntegrationTest.php new file mode 100644 index 000000000..a5f5d36ce --- /dev/null +++ b/php/tests/integration/TelemetrySessionIntegrationTest.php @@ -0,0 +1,115 @@ +setClientType(ClientType::PRODUCER); + $serverSettings->setSettings($echoedSettings); + + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', [$serverSettings]); + + $session = TelemetrySession::getInstance($mock, $this->endpoints, 'test-client'); + + $command = new TelemetryCommand(); + $requestSettings = new Settings(); + $requestSettings->setClientType(ClientType::PRODUCER); + $command->setSettings($requestSettings); + + $result = $session->syncSettings($command); + $this->assertTrue($result); + } + + public function testSettingsWithSubscription() + { + $mock = GrpcMockHelper::createMockClient(); + + $serverSettings = new TelemetryCommand(); + $echoedSettings = new Settings(); + $echoedSettings->setClientType(ClientType::SIMPLE_CONSUMER); + $subscription = new Subscription(); + $group = new Resource(); + $group->setName('test-group'); + $subscription->setGroup($group); + $echoedSettings->setSubscription($subscription); + $serverSettings->setSettings($echoedSettings); + + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', [$serverSettings]); + + $session = TelemetrySession::getInstance($mock, $this->endpoints, 'test-client'); + + $command = new TelemetryCommand(); + $requestSettings = new Settings(); + $requestSettings->setClientType(ClientType::SIMPLE_CONSUMER); + $command->setSettings($requestSettings); + + $result = $session->syncSettings($command); + $this->assertTrue($result); + } + + public function testSyncSettingsTimeout() + { + $mock = GrpcMockHelper::createMockClient(); + // No response from server — times out + GrpcMockHelper::mockBidiStreamCall($mock, 'Telemetry', []); + + $session = TelemetrySession::getInstance($mock, $this->endpoints, 'test-client'); + + $command = new TelemetryCommand(); + $requestSettings = new Settings(); + $requestSettings->setClientType(ClientType::PRODUCER); + $command->setSettings($requestSettings); + + $result = $session->syncSettings($command); + // No server Settings response within the timeout means the sync failed: + // callers must not treat startup as successful without server-accepted settings + $this->assertFalse($result); + $this->assertFalse($session->isSettingsSynced()); + } + + public function testSingletonReturnsSameInstance() + { + $mock = GrpcMockHelper::createMockClient(); + + $session1 = TelemetrySession::getInstance($mock, $this->endpoints, 'client-1'); + $session2 = TelemetrySession::getInstance($mock, $this->endpoints, 'client-1'); + + $this->assertSame($session1, $session2); + } +} diff --git a/php/tests/integration/TransactionIntegrationTest.php b/php/tests/integration/TransactionIntegrationTest.php new file mode 100644 index 000000000..a98359aea --- /dev/null +++ b/php/tests/integration/TransactionIntegrationTest.php @@ -0,0 +1,158 @@ +createMockProducer()); + + $msg = new Message(); + $msg->setBody('TX message'); + + $tx->tryAddMessage($msg); + $this->assertTrue(true); + } + + public function testTryAddReceiptAddsReceipt() + { + $tx = new Transaction($this->createMockProducer()); + + $msg = new Message(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $msg->setTopic($topicResource); + $msg->setBody('TX message'); + $tx->tryAddMessage($msg); + + $tx->tryAddReceipt($msg, [ + 'messageId' => 'msg-001', + 'transactionId' => 'tx-001', + ]); + + $receipts = $tx->getReceipts(); + $this->assertCount(1, $receipts); + $this->assertEquals('msg-001', $receipts[0]['messageId']); + $this->assertEquals('tx-001', $receipts[0]['transactionId']); + } + + public function testCommitFailsWithoutReceipt() + { + $tx = new Transaction($this->createMockProducer()); + + $msg = new Message(); + $msg->setBody('TX message'); + $tx->tryAddMessage($msg); + + $this->expectException(\RuntimeException::class); + $tx->commit(); + } + + public function testRollbackFailsWithoutReceipt() + { + $tx = new Transaction($this->createMockProducer()); + + $msg = new Message(); + $msg->setBody('TX message'); + $tx->tryAddMessage($msg); + + $this->expectException(\RuntimeException::class); + $tx->rollback(); + } + + public function testTryAddMessageAfterCommitThrows() + { + $tx = new Transaction($this->createMockProducer()); + + $msg1 = new Message(); + $topicResource = new Resource(); + $topicResource->setName('test-topic'); + $msg1->setTopic($topicResource); + $msg1->setBody('msg1'); + $tx->tryAddMessage($msg1); + $tx->tryAddReceipt($msg1, [ + 'messageId' => 'msg-001', + 'transactionId' => 'tx-001', + ]); + $tx->commit(); + + $this->expectException(\RuntimeException::class); + $msg2 = new Message(); + $msg2->setBody('msg2'); + $tx->tryAddMessage($msg2); + } + + public function testTryAddSecondMessageThrows() + { + $tx = new Transaction($this->createMockProducer()); + + $msg1 = new Message(); + $msg1->setBody('msg1'); + $tx->tryAddMessage($msg1); + + $this->expectException(\InvalidArgumentException::class); + $msg2 = new Message(); + $msg2->setBody('msg2'); + $tx->tryAddMessage($msg2); + } + + public function testIsCommittedAndIsRolledBack() + { + $tx = new Transaction($this->createMockProducer()); + $this->assertFalse($tx->isCommitted()); + $this->assertFalse($tx->isRolledBack()); + } +} diff --git a/php/tests/integration/TransactionRealIntegrationTest.php b/php/tests/integration/TransactionRealIntegrationTest.php new file mode 100644 index 000000000..ec1e07160 --- /dev/null +++ b/php/tests/integration/TransactionRealIntegrationTest.php @@ -0,0 +1,444 @@ +markTestSkipped('RocketMQ broker not available at 127.0.0.1:8081 (real-broker integration test)'); + } + fclose($socket); + } + + protected function tearDown(): void + { + if ($this->producer !== null) { + try { + $this->producer->shutdown(); + } catch (\Throwable $e) { + // ignore + } + $this->producer = null; + } + parent::tearDown(); + } + + /** + * Create a Producer connected to the real cluster. + */ + private function createProducer(): Producer + { + $producer = new Producer(self::ENDPOINTS, [ + 'topics' => [self::TOPIC], + 'sslEnabled' => false, + 'maxAttempts' => 3, + 'requestTimeout' => 5000, + ]); + + // Set a TransactionChecker (required for beginTransaction) + $producer->setTransactionChecker(new class implements TransactionChecker { + public function check(MessageView $messageView): int + { + // Default: commit orphaned transactions + return TransactionResolution::COMMIT; + } + }); + + $producer->start(); + $this->producer = $producer; + return $producer; + } + + /** + * Build a message for the test topic. + */ + private function buildMessage(string $body, string $tag = ''): Message + { + $topicResource = new Resource(); + $topicResource->setName(self::TOPIC); + + $sysProps = new SystemProperties(); + if (!empty($tag)) { + $sysProps->setTag($tag); + } + $sysProps->setKeys(['tx-test-' . uniqid()]); + + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody($body); + $message->setSystemProperties($sysProps); + + return $message; + } + + // ==================== Test Cases ==================== + + /** + * Test 1: Send transaction half-message and COMMIT. + * Verifies: half-message sends successfully, commit completes without error. + */ + public function testTransactionCommit(): void + { + $producer = $this->createProducer(); + + $message = $this->buildMessage('Transaction commit test - ' . date('Y-m-d H:i:s'), 'commit-tag'); + $transaction = $producer->beginTransaction(); + + // Send half-message + $result = $producer->sendWithTransaction($message, $transaction); + + $this->assertArrayHasKey('messageId', $result, 'Send result should contain messageId'); + $this->assertArrayHasKey('transactionId', $result, 'Send result should contain transactionId'); + $this->assertNotEmpty($result['messageId'], 'messageId should not be empty'); + $this->assertNotEmpty($result['transactionId'], 'transactionId should not be empty'); + + echo "\n[COMMIT TEST] Half-message sent: messageId={$result['messageId']}, transactionId={$result['transactionId']}"; + + // Commit the transaction + $transaction->commit(); + + $this->assertTrue($transaction->isCommitted(), 'Transaction should be committed'); + $this->assertFalse($transaction->isRolledBack(), 'Transaction should not be rolled back'); + + echo "\n[COMMIT TEST] Transaction committed successfully\n"; + } + + /** + * Test 2: Send transaction half-message and ROLLBACK. + * Verifies: half-message sends successfully, rollback completes without error. + */ + public function testTransactionRollback(): void + { + $producer = $this->createProducer(); + + $message = $this->buildMessage('Transaction rollback test - ' . date('Y-m-d H:i:s'), 'rollback-tag'); + $transaction = $producer->beginTransaction(); + + // Send half-message + $result = $producer->sendWithTransaction($message, $transaction); + + $this->assertArrayHasKey('messageId', $result, 'Send result should contain messageId'); + $this->assertArrayHasKey('transactionId', $result, 'Send result should contain transactionId'); + $this->assertNotEmpty($result['messageId'], 'messageId should not be empty'); + $this->assertNotEmpty($result['transactionId'], 'transactionId should not be empty'); + + echo "\n[ROLLBACK TEST] Half-message sent: messageId={$result['messageId']}, transactionId={$result['transactionId']}"; + + // Rollback the transaction + $transaction->rollback(); + + $this->assertTrue($transaction->isRolledBack(), 'Transaction should be rolled back'); + $this->assertFalse($transaction->isCommitted(), 'Transaction should not be committed'); + + echo "\n[ROLLBACK TEST] Transaction rolled back successfully\n"; + } + + /** + * Test 3: Transaction with LocalTransactionExecuter that returns COMMIT. + * Verifies: executor is called, transaction auto-commits. + */ + public function testTransactionWithExecutorCommit(): void + { + $producer = $this->createProducer(); + + $tracker = new \stdClass(); + $tracker->called = false; + $executor = new class($tracker) implements LocalTransactionExecuter { + private \stdClass $tracker; + public function __construct(\stdClass $tracker) + { + $this->tracker = $tracker; + } + public function execute(MessageView $messageView): int + { + $this->tracker->called = true; + echo "\n[EXECUTOR-COMMIT TEST] Local transaction executed, returning COMMIT"; + return TransactionResolution::COMMIT; + } + }; + + $message = $this->buildMessage('Transaction executor-commit test - ' . date('Y-m-d H:i:s'), 'exec-commit-tag'); + $transaction = $producer->beginTransaction(); + + // Send half-message with executor - auto commits + $result = $producer->sendWithTransaction($message, $transaction, $executor); + + $this->assertArrayHasKey('messageId', $result, 'Send result should contain messageId'); + $this->assertTrue($tracker->called, 'Executor should have been called'); + $this->assertTrue($transaction->isCommitted(), 'Transaction should be auto-committed by executor'); + + echo "\n[EXECUTOR-COMMIT TEST] Transaction auto-committed via executor, messageId={$result['messageId']}\n"; + } + + /** + * Test 4: Transaction with LocalTransactionExecuter that returns ROLLBACK. + * Verifies: executor is called, transaction auto-rolls back. + */ + public function testTransactionWithExecutorRollback(): void + { + $producer = $this->createProducer(); + + $tracker = new \stdClass(); + $tracker->called = false; + $executor = new class($tracker) implements LocalTransactionExecuter { + private \stdClass $tracker; + public function __construct(\stdClass $tracker) + { + $this->tracker = $tracker; + } + public function execute(MessageView $messageView): int + { + $this->tracker->called = true; + echo "\n[EXECUTOR-ROLLBACK TEST] Local transaction executed, returning ROLLBACK"; + return TransactionResolution::ROLLBACK; + } + }; + + $message = $this->buildMessage('Transaction executor-rollback test - ' . date('Y-m-d H:i:s'), 'exec-rollback-tag'); + $transaction = $producer->beginTransaction(); + + // Send half-message with executor - auto rolls back + $result = $producer->sendWithTransaction($message, $transaction, $executor); + + $this->assertArrayHasKey('messageId', $result, 'Send result should contain messageId'); + $this->assertTrue($tracker->called, 'Executor should have been called'); + $this->assertTrue($transaction->isRolledBack(), 'Transaction should be auto-rolled-back by executor'); + + echo "\n[EXECUTOR-ROLLBACK TEST] Transaction auto-rolled-back via executor, messageId={$result['messageId']}\n"; + } + + /** + * Test 5: Multiple sequential transaction commits. + * Verifies: can send and commit multiple transactions in sequence. + */ + public function testMultipleSequentialTransactionCommits(): void + { + $producer = $this->createProducer(); + $commitCount = 3; + + for ($i = 1; $i <= $commitCount; $i++) { + $message = $this->buildMessage("Sequential tx commit #{$i} - " . date('Y-m-d H:i:s'), "seq-commit-{$i}"); + $transaction = $producer->beginTransaction(); + + $result = $producer->sendWithTransaction($message, $transaction); + $this->assertNotEmpty($result['messageId'], "Message #{$i} should have messageId"); + + $transaction->commit(); + $this->assertTrue($transaction->isCommitted(), "Transaction #{$i} should be committed"); + + echo "\n[SEQUENTIAL TEST] Transaction #{$i} committed: messageId={$result['messageId']}"; + } + + echo "\n[SEQUENTIAL TEST] All {$commitCount} transactions committed successfully\n"; + } + + /** + * Test 6: Transaction state transitions - cannot commit after rollback and vice versa. + */ + public function testTransactionStateTransitions(): void + { + $producer = $this->createProducer(); + + // Test: cannot commit after rollback + $message1 = $this->buildMessage('State test - rollback then commit attempt'); + $tx1 = $producer->beginTransaction(); + $producer->sendWithTransaction($message1, $tx1); + $tx1->rollback(); + + $this->expectException(\RuntimeException::class); + $tx1->commit(); // should throw + } + + /** + * Test 7: Cannot rollback after commit. + */ + public function testCannotRollbackAfterCommit(): void + { + $producer = $this->createProducer(); + + $message = $this->buildMessage('State test - commit then rollback attempt'); + $tx = $producer->beginTransaction(); + $producer->sendWithTransaction($message, $tx); + $tx->commit(); + + $this->expectException(\RuntimeException::class); + $tx->rollback(); // should throw + } + + /** + * Test 8: Transaction without TransactionChecker should fail on beginTransaction. + */ + public function testBeginTransactionWithoutCheckerThrows(): void + { + $producer = new Producer(self::ENDPOINTS, [ + 'topics' => [self::TOPIC], + 'sslEnabled' => false, + 'maxAttempts' => 3, + 'requestTimeout' => 5000, + ]); + // Do NOT set TransactionChecker + $producer->start(); + $this->producer = $producer; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/TransactionChecker/i'); + $producer->beginTransaction(); + } + + /** + * Test 9: Send transaction message on Producer that is not started should fail. + */ + public function testSendTransactionOnStoppedProducerThrows(): void + { + $producer = new Producer(self::ENDPOINTS, [ + 'topics' => [self::TOPIC], + 'sslEnabled' => false, + ]); + $producer->setTransactionChecker(new class implements TransactionChecker { + public function check(MessageView $messageView): int + { + return TransactionResolution::COMMIT; + } + }); + // Do NOT start + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/not running/i'); + $producer->beginTransaction(); + } + + /** + * Test 10: Transaction message should not allow messageGroup (FIFO). + */ + public function testTransactionMessageRejectsMessageGroup(): void + { + $producer = $this->createProducer(); + + $topicResource = new Resource(); + $topicResource->setName(self::TOPIC); + + $sysProps = new SystemProperties(); + $sysProps->setMessageGroup('test-group'); + + $message = new Message(); + $message->setTopic($topicResource); + $message->setBody('FIFO message in transaction'); + $message->setSystemProperties($sysProps); + + $transaction = $producer->beginTransaction(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/messageGroup|deliveryTimestamp|liteTopic|priority/i'); + $producer->sendWithTransaction($message, $transaction); + } + + /** + * Test 11: TransactionChecker returning COMMIT resolves orphaned transactions. + * Verifies: the checker is set correctly and the producer starts without errors. + */ + public function testTransactionCheckerIsRegistered(): void + { + $checkerCalled = false; + $producer = $this->createProducer(); + + // The checker was set in createProducer(). We verify the producer starts + // successfully with a TransactionChecker registered (the callback is wired up + // during start()). If the checker registration failed, start() would still succeed + // but orphaned transactions would not be handled. + $this->assertTrue($producer->isRunning(), 'Producer should be running with TransactionChecker registered'); + + // Verify beginTransaction works (requires TransactionChecker) + $tx = $producer->beginTransaction(); + $this->assertInstanceOf(Transaction::class, $tx, 'beginTransaction should return a Transaction instance'); + + echo "\n[CHECKER TEST] TransactionChecker registered and beginTransaction works\n"; + } + + /** + * Test 12: Mixed commit and rollback sequence. + * Verifies: interleaved commit and rollback operations work correctly. + */ + public function testMixedCommitAndRollbackSequence(): void + { + $producer = $this->createProducer(); + $operations = ['commit', 'rollback', 'commit', 'commit', 'rollback']; + + foreach ($operations as $i => $op) { + $message = $this->buildMessage("Mixed op #{$i}: {$op} - " . date('Y-m-d H:i:s'), "mixed-{$op}"); + $transaction = $producer->beginTransaction(); + $result = $producer->sendWithTransaction($message, $transaction); + + $this->assertNotEmpty($result['messageId'], "Message #{$i} should have messageId"); + + if ($op === 'commit') { + $transaction->commit(); + $this->assertTrue($transaction->isCommitted(), "Op #{$i} should be committed"); + } else { + $transaction->rollback(); + $this->assertTrue($transaction->isRolledBack(), "Op #{$i} should be rolled back"); + } + + echo "\n[MIXED TEST] Op #{$i} ({$op}): messageId={$result['messageId']}"; + } + + echo "\n[MIXED TEST] All " . count($operations) . " operations completed successfully\n"; + } +} diff --git a/style/codespell/ignore_words.txt b/style/codespell/ignore_words.txt index 7b22abddf..634fb7515 100644 --- a/style/codespell/ignore_words.txt +++ b/style/codespell/ignore_words.txt @@ -7,3 +7,5 @@ atleast # package-lock.json false positives (base64 hashes and package binary names) ba marge +# SipHash24.php: 64-bit arithmetic split into low/high 32-bit variable +aLo