diff --git a/Couchbase/Collection.php b/Couchbase/Collection.php index 17e1d4de..ea3f1d0a 100644 --- a/Couchbase/Collection.php +++ b/Couchbase/Collection.php @@ -24,8 +24,11 @@ use Couchbase\Exception\DocumentExistsException; use Couchbase\Exception\DocumentIrretrievableException; use Couchbase\Exception\DocumentNotFoundException; +use Couchbase\Exception\DocumentNotFoundOnReplicaException; use Couchbase\Exception\CouchbaseException; use Couchbase\Exception\InvalidArgumentException; +use Couchbase\Exception\ReplicaIndexCurrentlyUnavailableException; +use Couchbase\Exception\ReplicaIndexOutOfBoundsException; use Couchbase\Exception\TimeoutException; use Couchbase\Exception\UnsupportedOperationException; use Couchbase\Management\CollectionQueryIndexManager; @@ -290,6 +293,44 @@ function (ObservabilityHandler $obsHandler) use ($id, $options) { ); } + /** + * Reads from the replica the strategy selects. + * + * @param string $id the key of the document + * @param GetReplicaStrategy $strategy selects the replica to read from + * @param GetReplicaOptions|null $options the options to use for the operation + * + * @return GetReplicaResult + * + * @throws DocumentNotFoundOnReplicaException + * @throws ReplicaIndexOutOfBoundsException + * @throws ReplicaIndexCurrentlyUnavailableException + * @throws TimeoutException + * @throws CouchbaseException + * @since 4.5.0 + */ + public function getReplica(string $id, GetReplicaStrategy $strategy, ?GetReplicaOptions $options = null): GetReplicaResult + { + return $this->observability->recordOperation( + ObservabilityConstants::OP_GET_REPLICA, + GetReplicaOptions::getParentSpan($options), + function (ObservabilityHandler $obsHandler) use ($id, $strategy, $options) { + $function = COUCHBASE_EXTENSION_NAMESPACE . '\\documentGetReplica'; + $response = $function( + $this->core, + $this->bucketName, + $this->scopeName, + $this->name, + $id, + GetReplicaStrategy::export($strategy), + GetReplicaOptions::export($options), + $obsHandler->getCoreSpansArray() + ); + return new GetReplicaResult($response, GetReplicaOptions::getTranscoder($options)); + } + ); + } + /** * Gets a document from the active server and all replica servers in the cluster. * Returns an array of documents, one per server. diff --git a/Couchbase/CollectionInterface.php b/Couchbase/CollectionInterface.php index 9a5af7fd..64c8db28 100644 --- a/Couchbase/CollectionInterface.php +++ b/Couchbase/CollectionInterface.php @@ -40,6 +40,8 @@ public function getAndTouch(string $id, $expiry, ?GetAndTouchOptions $options = public function getAnyReplica(string $id, ?GetAnyReplicaOptions $options = null): GetReplicaResult; + public function getReplica(string $id, GetReplicaStrategy $strategy, ?GetReplicaOptions $options = null): GetReplicaResult; + public function getAllReplicas(string $id, ?GetAllReplicasOptions $options = null): array; public function upsert(string $id, $value, ?UpsertOptions $options = null): MutationResult; diff --git a/Couchbase/Exception/DocumentNotFoundOnReplicaException.php b/Couchbase/Exception/DocumentNotFoundOnReplicaException.php new file mode 100644 index 00000000..538ea746 --- /dev/null +++ b/Couchbase/Exception/DocumentNotFoundOnReplicaException.php @@ -0,0 +1,28 @@ +transcoder = JsonTranscoder::getInstance(); + } + + /** + * Static helper to keep code more readable + * + * @return GetReplicaOptions + */ + public static function build(): GetReplicaOptions + { + return new GetReplicaOptions(); + } + + /** + * Sets the operation timeout in milliseconds. + * + * @param int $milliseconds the operation timeout to apply + * + * @return GetReplicaOptions + */ + public function timeout(int $milliseconds): GetReplicaOptions + { + $this->timeoutMilliseconds = $milliseconds; + return $this; + } + + /** + * Associate custom transcoder with the request. + * + * @param Transcoder $transcoder + * + * @return GetReplicaOptions + */ + public function transcoder(Transcoder $transcoder): GetReplicaOptions + { + $this->transcoder = $transcoder; + return $this; + } + + /** + * Sets the parent span. + * + * @param RequestSpan $parentSpan the parent span + * + * @return GetReplicaOptions + */ + public function parentSpan(RequestSpan $parentSpan): GetReplicaOptions + { + $this->parentSpan = $parentSpan; + return $this; + } + + /** + * @internal + */ + public static function getParentSpan(?GetReplicaOptions $options): ?RequestSpan + { + return $options?->parentSpan; + } + + /** + * Returns associated transcoder. + * + * @param GetReplicaOptions|null $options + * + * @return Transcoder + */ + public static function getTranscoder(?GetReplicaOptions $options): Transcoder + { + if ($options == null) { + return JsonTranscoder::getInstance(); + } + return $options->transcoder; + } + + /** + * @param GetReplicaOptions|null $options + * + * @return array + * @internal + */ + public static function export(?GetReplicaOptions $options): array + { + if ($options == null) { + return []; + } + return [ + 'timeoutMilliseconds' => $options->timeoutMilliseconds, + ]; + } +} diff --git a/Couchbase/GetReplicaStrategy.php b/Couchbase/GetReplicaStrategy.php new file mode 100644 index 00000000..053d2898 --- /dev/null +++ b/Couchbase/GetReplicaStrategy.php @@ -0,0 +1,84 @@ +replicaIndex = $replicaIndex; + } + + /** + * Read from the replica at the given index. + * + * @param int $replicaIndex the replica to read from + * + * @see ReplicaIndex + * + * @return GetReplicaStrategy + */ + public static function fromIndex(int $replicaIndex): GetReplicaStrategy + { + return new GetReplicaStrategy($replicaIndex); + } + + /** + * Resolve an index that cannot be read as requested to the next one that can, instead of + * failing with ReplicaIndexOutOfBoundsException or ReplicaIndexCurrentlyUnavailableException. + * + * The search walks the bucket's configured replica count, starting at the requested index + * modulo that count. Two failures remain: a bucket configured for no replicas still throws + * ReplicaIndexOutOfBoundsException, and a full lap that finds nothing readable throws + * ReplicaIndexCurrentlyUnavailableException. + * + * @param bool $wrap whether to wrap around the available replicas + * + * @return GetReplicaStrategy + */ + public function wrap(bool $wrap): GetReplicaStrategy + { + $this->wrap = $wrap; + return $this; + } + + /** + * @param GetReplicaStrategy $strategy + * + * @return array + * @internal + */ + public static function export(GetReplicaStrategy $strategy): array + { + return [ + 'replicaIndex' => $strategy->replicaIndex, + 'wrap' => $strategy->wrap, + ]; + } +} diff --git a/Couchbase/Protostellar/Collection.php b/Couchbase/Protostellar/Collection.php index 420f4342..4ad60ae4 100644 --- a/Couchbase/Protostellar/Collection.php +++ b/Couchbase/Protostellar/Collection.php @@ -26,6 +26,7 @@ use Couchbase\Exception\DocumentIrretrievableException; use Couchbase\Exception\DocumentNotFoundException; use Couchbase\Exception\InvalidArgumentException; +use Couchbase\Exception\UnsupportedOperationException; use Couchbase\ExistsOptions; use Couchbase\ExistsResult; use Couchbase\GetAllReplicasOptions; @@ -33,7 +34,9 @@ use Couchbase\GetAndTouchOptions; use Couchbase\GetAnyReplicaOptions; use Couchbase\GetOptions; +use Couchbase\GetReplicaOptions; use Couchbase\GetReplicaResult; +use Couchbase\GetReplicaStrategy; use Couchbase\GetResult; use Couchbase\InsertOptions; use Couchbase\LookupInOptions; @@ -301,6 +304,14 @@ public function getAnyReplica(string $key, ?GetAnyReplicaOptions $options = null return KVResponseConverter::convertGetAnyReplicaResult($key, $response, $options)[0]; } + /** + * @throws UnsupportedOperationException + */ + public function getReplica(string $key, GetReplicaStrategy $strategy, ?GetReplicaOptions $options = null): GetReplicaResult + { + throw new UnsupportedOperationException("getReplica is not supported with the couchbase2 scheme yet"); + } + /** * @throws DocumentNotFoundException */ diff --git a/Couchbase/ReplicaIndex.php b/Couchbase/ReplicaIndex.php new file mode 100644 index 00000000..1a7a67ac --- /dev/null +++ b/Couchbase/ReplicaIndex.php @@ -0,0 +1,34 @@ +document_get_replica( + return_value, spans, bucket, scope, collection, id, strategy, options); + e.ec) { + couchbase_throw_exception(e); + RETURN_THROWS(); + } +} + PHP_FUNCTION(documentGetAndLock) { zval* connection = nullptr; @@ -4347,6 +4385,17 @@ ZEND_ARG_TYPE_INFO(0, options, IS_ARRAY, 1) ZEND_ARG_TYPE_INFO(1, spans, IS_ARRAY, 1) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_INFO_EX(ai_CouchbaseExtension_documentGetReplica, 0, 0, 6) +ZEND_ARG_INFO(0, connection) +ZEND_ARG_TYPE_INFO(0, bucket, IS_STRING, 0) +ZEND_ARG_TYPE_INFO(0, scope, IS_STRING, 0) +ZEND_ARG_TYPE_INFO(0, collection, IS_STRING, 0) +ZEND_ARG_TYPE_INFO(0, id, IS_STRING, 0) +ZEND_ARG_TYPE_INFO(0, strategy, IS_ARRAY, 0) +ZEND_ARG_TYPE_INFO(0, options, IS_ARRAY, 1) +ZEND_ARG_TYPE_INFO(1, spans, IS_ARRAY, 1) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_INFO_EX(ai_CouchbaseExtension_documentGetAndLock, 0, 0, 6) ZEND_ARG_INFO(0, connection) ZEND_ARG_TYPE_INFO(0, bucket, IS_STRING, 0) @@ -5249,6 +5298,7 @@ static zend_function_entry couchbase_functions[] = { ZEND_NS_FE("Couchbase\\Extension" COUCHBASE_NAMESPACE_ABI_SUFFIX, documentGet, ai_CouchbaseExtension_documentGet) ZEND_NS_FE("Couchbase\\Extension" COUCHBASE_NAMESPACE_ABI_SUFFIX, documentGetAnyReplica, ai_CouchbaseExtension_documentGetAnyReplica) ZEND_NS_FE("Couchbase\\Extension" COUCHBASE_NAMESPACE_ABI_SUFFIX, documentGetAllReplicas, ai_CouchbaseExtension_documentGetAllReplicas) + ZEND_NS_FE("Couchbase\\Extension" COUCHBASE_NAMESPACE_ABI_SUFFIX, documentGetReplica, ai_CouchbaseExtension_documentGetReplica) ZEND_NS_FE("Couchbase\\Extension" COUCHBASE_NAMESPACE_ABI_SUFFIX, documentGetAndTouch, ai_CouchbaseExtension_documentGetAndTouch) ZEND_NS_FE("Couchbase\\Extension" COUCHBASE_NAMESPACE_ABI_SUFFIX, documentGetAndLock, ai_CouchbaseExtension_documentGetAndLock) ZEND_NS_FE("Couchbase\\Extension" COUCHBASE_NAMESPACE_ABI_SUFFIX, documentUnlock, ai_CouchbaseExtension_documentUnlock) diff --git a/src/wrapper/common.cxx b/src/wrapper/common.cxx index 34edaeab..0d180190 100644 --- a/src/wrapper/common.cxx +++ b/src/wrapper/common.cxx @@ -56,6 +56,7 @@ zend_class_entry* document_exists_exception_ce; zend_class_entry* document_irretrievable_exception_ce; zend_class_entry* document_locked_exception_ce; zend_class_entry* document_not_found_exception_ce; +zend_class_entry* document_not_found_on_replica_exception_ce; zend_class_entry* document_not_locked_exception_ce; zend_class_entry* document_not_json_exception_ce; zend_class_entry* durability_ambiguous_exception_ce; @@ -86,6 +87,8 @@ zend_class_entry* path_too_deep_exception_ce; zend_class_entry* permission_denied_exception_ce; zend_class_entry* planning_failure_exception_ce; zend_class_entry* prepared_statement_failure_exception_ce; +zend_class_entry* replica_index_currently_unavailable_exception_ce; +zend_class_entry* replica_index_out_of_bounds_exception_ce; zend_class_entry* request_canceled_exception_ce; zend_class_entry* scope_exists_exception_ce; zend_class_entry* scope_not_found_exception_ce; @@ -226,6 +229,12 @@ initialize_exceptions(const zend_function_entry* exception_functions) "DocumentNotFoundException", nullptr); document_not_found_exception_ce = zend_register_internal_class_ex(&ce, couchbase_exception_ce); + INIT_NS_CLASS_ENTRY(ce, + "Couchbase\\Exception" COUCHBASE_NAMESPACE_ABI_SUFFIX, + "DocumentNotFoundOnReplicaException", + nullptr); + document_not_found_on_replica_exception_ce = + zend_register_internal_class_ex(&ce, document_not_found_exception_ce); INIT_NS_CLASS_ENTRY(ce, "Couchbase\\Exception" COUCHBASE_NAMESPACE_ABI_SUFFIX, "DocumentNotLockedException", @@ -341,6 +350,18 @@ initialize_exceptions(const zend_function_entry* exception_functions) nullptr); prepared_statement_failure_exception_ce = zend_register_internal_class_ex(&ce, couchbase_exception_ce); + INIT_NS_CLASS_ENTRY(ce, + "Couchbase\\Exception" COUCHBASE_NAMESPACE_ABI_SUFFIX, + "ReplicaIndexCurrentlyUnavailableException", + nullptr); + replica_index_currently_unavailable_exception_ce = + zend_register_internal_class_ex(&ce, couchbase_exception_ce); + INIT_NS_CLASS_ENTRY(ce, + "Couchbase\\Exception" COUCHBASE_NAMESPACE_ABI_SUFFIX, + "ReplicaIndexOutOfBoundsException", + nullptr); + replica_index_out_of_bounds_exception_ce = + zend_register_internal_class_ex(&ce, couchbase_exception_ce); INIT_NS_CLASS_ENTRY( ce, "Couchbase\\Exception" COUCHBASE_NAMESPACE_ABI_SUFFIX, "RequestCanceledException", nullptr); request_canceled_exception_ce = zend_register_internal_class_ex(&ce, couchbase_exception_ce); @@ -484,6 +505,8 @@ initialize_exception_aliases() document_locked_exception_ce); zend_register_class_alias("Couchbase\\Exception\\DocumentNotFoundException", document_not_found_exception_ce); + zend_register_class_alias("Couchbase\\Exception\\DocumentNotFoundOnReplicaException", + document_not_found_on_replica_exception_ce); zend_register_class_alias("Couchbase\\Exception\\DocumentNotLockedException", document_not_locked_exception_ce); zend_register_class_alias("Couchbase\\Exception\\DocumentNotJsonException", @@ -541,6 +564,10 @@ initialize_exception_aliases() planning_failure_exception_ce); zend_register_class_alias("Couchbase\\Exception\\PreparedStatementFailureException", prepared_statement_failure_exception_ce); + zend_register_class_alias("Couchbase\\Exception\\ReplicaIndexCurrentlyUnavailableException", + replica_index_currently_unavailable_exception_ce); + zend_register_class_alias("Couchbase\\Exception\\ReplicaIndexOutOfBoundsException", + replica_index_out_of_bounds_exception_ce); zend_register_class_alias("Couchbase\\Exception\\RequestCanceledException", request_canceled_exception_ce); zend_register_class_alias("Couchbase\\Exception\\ScopeExistsException", @@ -655,10 +682,16 @@ map_error_to_exception(const core_error_info& info) switch (static_cast(info.ec.value())) { case couchbase::errc::key_value::document_not_found: return document_not_found_exception_ce; + case couchbase::errc::key_value::document_not_found_on_replica: + return document_not_found_on_replica_exception_ce; case couchbase::errc::key_value::document_not_locked: return document_not_locked_exception_ce; case couchbase::errc::key_value::document_irretrievable: return document_irretrievable_exception_ce; + case couchbase::errc::key_value::replica_index_out_of_bounds: + return replica_index_out_of_bounds_exception_ce; + case couchbase::errc::key_value::replica_index_currently_unavailable: + return replica_index_currently_unavailable_exception_ce; case couchbase::errc::key_value::document_locked: return document_locked_exception_ce; case couchbase::errc::key_value::document_exists: diff --git a/src/wrapper/connection_handle.cxx b/src/wrapper/connection_handle.cxx index f65843e2..7e339c30 100644 --- a/src/wrapper/connection_handle.cxx +++ b/src/wrapper/connection_handle.cxx @@ -1550,6 +1550,51 @@ connection_handle::document_get_all_replicas(zval* return_value, return {}; } +COUCHBASE_API +auto +connection_handle::document_get_replica(zval* return_value, + zval* spans, + const zend_string* bucket, + const zend_string* scope, + const zend_string* collection, + const zend_string* id, + const zval* strategy, + const zval* options) -> core_error_info +{ + couchbase::core::operations::get_replica_request req{ + couchbase::core::document_id{ + cb_string_new(bucket), + cb_string_new(scope), + cb_string_new(collection), + cb_string_new(id), + }, + }; + if (auto e = cb_assign_timeout(req, options); e.ec) { + return e; + } + + couchbase::core::impl::replica_selection selection{}; + if (auto e = cb_assign_integer(selection.replica_index, strategy, "replicaIndex"); e.ec) { + return e; + } + if (auto e = cb_assign_boolean(selection.wrap, strategy, "wrap"); e.ec) { + return e; + } + req.selection = selection; + + auto [resp, err] = impl_->key_value_execute(__func__, std::move(req), spans); + if (err.ec) { + if (auto mapped = couchbase::core::impl::make_get_replica_error(resp.ctx).ec(); mapped) { + err.ec = mapped; + } + return err; + } + + cb_create_get_result(return_value, resp, id); + add_assoc_bool(return_value, "isReplica", true); + return {}; +} + COUCHBASE_API auto connection_handle::document_get_and_lock(zval* return_value, diff --git a/src/wrapper/connection_handle.hxx b/src/wrapper/connection_handle.hxx index 14740e58..3d37f1e8 100644 --- a/src/wrapper/connection_handle.hxx +++ b/src/wrapper/connection_handle.hxx @@ -221,6 +221,16 @@ public: const zend_string* id, const zval* options) -> core_error_info; + COUCHBASE_API + auto document_get_replica(zval* return_value, + zval* spans, + const zend_string* bucket, + const zend_string* scope, + const zend_string* collection, + const zend_string* id, + const zval* strategy, + const zval* options) -> core_error_info; + COUCHBASE_API auto document_get_and_lock(zval* return_value, zval* spans, diff --git a/tests/KeyValueGetReplicaTest.php b/tests/KeyValueGetReplicaTest.php index c1334d56..e49a6947 100644 --- a/tests/KeyValueGetReplicaTest.php +++ b/tests/KeyValueGetReplicaTest.php @@ -20,9 +20,13 @@ use Couchbase\Exception\DocumentIrretrievableException; use Couchbase\Exception\DocumentNotFoundException; +use Couchbase\Exception\DocumentNotFoundOnReplicaException; +use Couchbase\Exception\ReplicaIndexOutOfBoundsException; use Couchbase\GetAllReplicasOptions; use Couchbase\GetAnyReplicaOptions; +use Couchbase\GetReplicaStrategy; use Couchbase\ReadPreference; +use Couchbase\ReplicaIndex; use Couchbase\UpsertOptions; use Couchbase\DurabilityLevel; @@ -66,6 +70,65 @@ public function testGetAllReplicasReturnCorrectValue() $this->assertTrue($seenActiveVersion); } + public function testGetReplicaReturnsCorrectValue() + { + $this->skipIfProtostellar(); + $this->skipIfReplicasAreNotConfigured(); + + $id = $this->uniqueId(); + $collection = $this->defaultCollection(); + $opts = UpsertOptions::build()->durabilityLevel(DurabilityLevel::MAJORITY_AND_PERSIST_TO_ACTIVE); + $res = $collection->upsert($id, ["answer" => 42], $opts); + $cas = $res->cas(); + $this->assertNotNull($cas); + sleep(1); + $res = $collection->getReplica($id, GetReplicaStrategy::fromIndex(ReplicaIndex::FIRST)); + $this->assertEquals(["answer" => 42], $res->content()); + $this->assertTrue($res->isReplica()); + } + + public function testGetReplicaWithWrapReturnsCorrectValue() + { + $this->skipIfProtostellar(); + $this->skipIfReplicasAreNotConfigured(); + + $id = $this->uniqueId(); + $collection = $this->defaultCollection(); + $opts = UpsertOptions::build()->durabilityLevel(DurabilityLevel::MAJORITY_AND_PERSIST_TO_ACTIVE); + $res = $collection->upsert($id, ["answer" => 42], $opts); + $cas = $res->cas(); + $this->assertNotNull($cas); + sleep(1); + $res = $collection->getReplica($id, GetReplicaStrategy::fromIndex(ReplicaIndex::THIRD + 1)->wrap(true)); + $this->assertEquals(["answer" => 42], $res->content()); + $this->assertTrue($res->isReplica()); + } + + public function testGetReplicaThrowsDocumentNotFoundOnReplicaExceptionForMissingId() + { + $this->skipIfProtostellar(); + $this->skipIfReplicasAreNotConfigured(); + + $id = $this->uniqueId(); + $collection = $this->defaultCollection(); + $this->expectException(DocumentNotFoundOnReplicaException::class); + $collection->getReplica($id, GetReplicaStrategy::fromIndex(ReplicaIndex::FIRST)); + } + + public function testGetReplicaThrowsReplicaIndexOutOfBoundsExceptionForIndexBeyondReplicaCount() + { + $this->skipIfProtostellar(); + + $id = $this->uniqueId(); + $collection = $this->defaultCollection(); + $collection->upsert($id, ["answer" => 42]); + $this->expectException(ReplicaIndexOutOfBoundsException::class); + // One past ReplicaIndex::THIRD, the highest index the strategy supports: a bucket can be + // configured with at most 3 replicas, so this index is out of bounds regardless of how + // many replicas this environment's bucket actually has. + $collection->getReplica($id, GetReplicaStrategy::fromIndex(ReplicaIndex::THIRD + 1)); + } + public function testGetAllReplicasThrowsDocumentNotFoundExceptionForMissingId() { $this->skipIfReplicasAreNotConfigured(); diff --git a/tests/ObservabilityKeyValueOperationsTest.php b/tests/ObservabilityKeyValueOperationsTest.php index 8067c45f..0d2aa33a 100644 --- a/tests/ObservabilityKeyValueOperationsTest.php +++ b/tests/ObservabilityKeyValueOperationsTest.php @@ -37,6 +37,9 @@ use Couchbase\DurabilityLevel; use Couchbase\GetAllReplicasOptions; use Couchbase\GetAnyReplicaOptions; +use Couchbase\GetReplicaOptions; +use Couchbase\GetReplicaStrategy; +use Couchbase\ReplicaIndex; use Couchbase\LookupInAllReplicasOptions; use Couchbase\LookupInAnyReplicaOptions; use Helpers\Tracing\ParentSpanRequirement; @@ -385,6 +388,33 @@ public function testGetAnyReplica() $this->assertEquals(1, $getCount, "Expected exactly one 'get' operation span"); } + public function testGetReplica() + { + $this->skipIfProtostellar(); + $this->skipIfReplicasAreNotConfigured(); + + $collection = $this->defaultCollection(); + $collection->upsert( + self::EXISTING_DOC_ID, + ["foo" => "bar"], + UpsertOptions::build()->durabilityLevel(DurabilityLevel::MAJORITY_AND_PERSIST_TO_ACTIVE) + ); + sleep(1); + $this->tracer()->reset(); + $this->meter()->reset(); + + $collection->getReplica( + self::EXISTING_DOC_ID, + GetReplicaStrategy::fromIndex(ReplicaIndex::FIRST), + GetReplicaOptions::build() + ->parentSpan($this->parentSpan()) + ); + + $getReplicaSpan = $this->tracer()->getSpans(null, $this->parentSpan())[0]; + $this->assertKvOperationSpan($getReplicaSpan, "get_replica", $this->parentSpan()); + $this->assertKvOperationMetrics(1, "get_replica"); + } + public function testLookupInAllReplicas() { $this->skipIfUnsupported($this->version()->supportsServerGroupReplicaReads());