diff --git a/src/Client.php b/src/Client.php index f4e7cfa..25436df 100644 --- a/src/Client.php +++ b/src/Client.php @@ -42,6 +42,11 @@ class Client */ private bool $isConnected = false; + /** + * Whether the next operation must establish a fresh physical connection. + */ + private bool $reconnect = false; + /** * Socket / receive idle timeout in seconds. * @@ -86,6 +91,8 @@ class Client public const TRANSACTION_TIMEOUT_ERROR = 50; public const TRANSACTION_ABORTED_ERROR = 251; + private const PRIMARY_CHANGE_ERRORS = [189, 10107, 11602, 13435, 13436]; + // Transaction states public const TRANSACTION_NONE = 'none'; public const TRANSACTION_STARTING = 'starting'; @@ -1689,8 +1696,10 @@ public function close(): void /** * Physically close a failed socket without writing session cleanup to it. */ - private function invalidate(): void + private function invalidate(bool $preserveSessions = false): void { + $sessions = $preserveSessions ? $this->sessions : []; + try { if ($this->client instanceof CoroutineClient) { @$this->client->close(); @@ -1701,6 +1710,10 @@ private function invalidate(): void // The transport may already be closed or only partially initialized. } finally { $this->reset(); + if ($preserveSessions) { + $this->sessions = $sessions; + $this->reconnect = true; + } } } @@ -1710,6 +1723,7 @@ private function invalidate(): void private function reset(): void { $this->isConnected = false; + $this->reconnect = false; $this->sessions = []; $this->clusterTime = null; $this->operationTime = null; @@ -1800,7 +1814,12 @@ private function parseResponse(string $response, int $responseLength): stdClass| $writeError = $result->writeErrors[0] ?? null; if (\is_object($writeError) && isset($writeError->errmsg, $writeError->code)) { - throw new Exception($writeError->errmsg, $writeError->code); + $code = (int)$writeError->code; + if (self::isPrimaryChange($code)) { + $this->invalidate(preserveSessions: true); + } + + throw new Exception($writeError->errmsg, $code); } $this->invalidate(); @@ -1811,6 +1830,10 @@ private function parseResponse(string $response, int $responseLength): stdClass| $code = (int)($result->code ?? 0); $name = (string)($result->codeName ?? 'MongoError'); + if (self::isPrimaryChange($code)) { + $this->invalidate(preserveSessions: true); + } + throw new Exception('E' . $code . ' ' . $name . ': ' . $result->errmsg, $code); } @@ -1831,6 +1854,11 @@ private function parseResponse(string $response, int $responseLength): stdClass| throw new Exception('Invalid unsuccessful response'); } + private static function isPrimaryChange(int $code): bool + { + return \in_array($code, self::PRIMARY_CHANGE_ERRORS, true); + } + /** * Check if an exception represents a transient transaction error. * @@ -2039,6 +2067,20 @@ public function getSessionState(array $session): array */ private function validateConnection(): void { + if ($this->reconnect) { + $sessions = $this->sessions; + $this->reconnect = false; + + try { + $this->connect(); + } finally { + $this->sessions = $sessions; + if (!$this->isConnected || !$this->client->isConnected()) { + $this->reconnect = true; + } + } + } + if (!$this->isConnected) { throw new Exception('Client is not connected to MongoDB'); } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 4cab1b3..d43e8ff 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -490,6 +490,141 @@ public function testDecodedMongoCommandErrorDoesNotCloseTransport(): void $this->assertTrue($transport->open); } + public function testPrimaryChangeErrorsCloseTransportAndPreserveSessions(): void + { + foreach ([189, 10107, 11602, 13435, 13436] as $code) { + $transport = new SyncTransportDouble(); + $transport->receives = [[ + 'result' => $this->frame([ + 'ok' => 0.0, + 'errmsg' => 'primary changed', + 'code' => $code, + 'codeName' => 'PrimaryChanged', + ]), + 'error' => 0, + ]]; + $client = $this->client($transport); + $this->seedState($client); + $sessions = $this->get($client, 'sessions'); + + $exception = $this->receiveException($client); + + try { + $this->assertSame($code, $exception->getCode()); + $this->assertSame([[true]], $transport->closes); + $this->assertSame($sessions, $this->get($client, 'sessions')); + $this->assertFalse($this->get($client, 'isConnected')); + $this->assertTrue($this->get($client, 'reconnect')); + $this->assertConnectionContextCleared($client); + } finally { + $this->set($client, 'sessions', []); + } + } + } + + public function testPrimaryChangeWriteErrorClosesTransportAndPreservesSessions(): void + { + $transport = new SyncTransportDouble(); + $transport->receives = [[ + 'result' => $this->frame([ + 'ok' => 1.0, + 'writeErrors' => [[ + 'errmsg' => 'not primary', + 'code' => 10107, + ]], + ]), + 'error' => 0, + ]]; + $client = $this->client($transport); + $this->seedState($client); + $sessions = $this->get($client, 'sessions'); + + $exception = $this->receiveException($client); + + try { + $this->assertSame(10107, $exception->getCode()); + $this->assertSame([[true]], $transport->closes); + $this->assertSame($sessions, $this->get($client, 'sessions')); + $this->assertFalse($this->get($client, 'isConnected')); + $this->assertTrue($this->get($client, 'reconnect')); + $this->assertConnectionContextCleared($client); + } finally { + $this->set($client, 'sessions', []); + } + } + + public function testPermanentWriteErrorDoesNotCloseTransport(): void + { + $transport = new SyncTransportDouble(); + $transport->receives = [[ + 'result' => $this->frame([ + 'ok' => 1.0, + 'writeErrors' => [[ + 'errmsg' => 'duplicate key', + 'code' => 11000, + ]], + ]), + 'error' => 0, + ]]; + $client = $this->client($transport); + + $exception = $this->receiveException($client); + + $this->assertSame(11000, $exception->getCode()); + $this->assertSame([], $transport->closes); + $this->assertTrue($transport->open); + } + + public function testTransactionRetriesOnAFreshTransportAfterPrimaryChange(): void + { + $transport = new SyncTransportDouble(); + $transport->receives = [ + [ + 'result' => $this->frame([ + 'ok' => 0.0, + 'errmsg' => 'not primary', + 'code' => 10107, + 'codeName' => 'NotWritablePrimary', + ]), + 'error' => 0, + ], + ['result' => $this->frame(['ok' => 1.0]), 'error' => 0], + ['result' => $this->frame(['ok' => 1.0]), 'error' => 0], + ['result' => $this->frame(['ok' => 1.0]), 'error' => 0], + ]; + $client = $this->reconnectingClient($transport); + $identifier = (object) ['id' => new Binary(str_repeat("\1", 16), Binary::TYPE_GENERIC)]; + $session = ['id' => $identifier, 'sessionId' => 'session']; + $this->set($client, 'isConnected', true); + $this->set($client, 'sessions', [ + 'session' => [ + 'id' => $identifier, + 'state' => Client::TRANSACTION_NONE, + 'txnNumber' => 0, + 'lastUse' => time(), + 'operationTime' => null, + 'clusterTime' => null, + 'options' => [], + 'retryableWriteNumber' => 0, + ], + ]); + + $result = $client->withTransaction( + $session, + fn (array $transaction): mixed => $client->query(['ping' => 1, 'session' => $transaction]), + ['retryDelayMs' => 0], + ); + $sessions = $this->get($client, 'sessions'); + + $this->assertSame(1.0, $result->ok); + $this->assertSame(Client::TRANSACTION_COMMITTED, $sessions['session']['state']); + $this->assertSame(2, $sessions['session']['txnNumber']); + $this->assertSame([[true]], $transport->closes); + $this->assertCount(1, $transport->connects); + $this->assertFalse($this->get($client, 'reconnect')); + $this->set($client, 'sessions', []); + } + private function assertStateCleared(Client $client): void { $this->assertFalse($this->get($client, 'isConnected')); @@ -562,7 +697,18 @@ private function reconnectingClient(SwooleClient|CoroutineClient $transport): Re private function seedState(Client $client): void { $this->seedConnectionContext($client); - $this->set($client, 'sessions', ['session' => ['id' => 'session']]); + $this->set($client, 'sessions', [ + 'session' => [ + 'id' => (object) ['id' => 'session'], + 'state' => Client::TRANSACTION_NONE, + 'txnNumber' => 0, + 'lastUse' => time(), + 'operationTime' => null, + 'clusterTime' => null, + 'options' => [], + 'retryableWriteNumber' => 0, + ], + ]); } private function seedConnectionContext(Client $client): void