diff --git a/google-cloud-spanner/Gemfile.lock b/google-cloud-spanner/Gemfile.lock index a7c2ba50..2bd4a276 100644 --- a/google-cloud-spanner/Gemfile.lock +++ b/google-cloud-spanner/Gemfile.lock @@ -7,7 +7,7 @@ PATH google-cloud-core (~> 1.7) google-cloud-spanner-admin-database-v1 (~> 1.4) google-cloud-spanner-admin-instance-v1 (~> 1.6) - google-cloud-spanner-v1 (~> 1.6) + google-cloud-spanner-v1 (~> 1.12) GEM remote: https://rubygems.org/ diff --git a/google-cloud-spanner/google-cloud-spanner.gemspec b/google-cloud-spanner/google-cloud-spanner.gemspec index 219adb61..9d2bbdc1 100644 --- a/google-cloud-spanner/google-cloud-spanner.gemspec +++ b/google-cloud-spanner/google-cloud-spanner.gemspec @@ -24,5 +24,5 @@ Gem::Specification.new do |gem| gem.add_dependency "google-cloud-core", "~> 1.7" gem.add_dependency "google-cloud-spanner-admin-database-v1", "~> 1.4" gem.add_dependency "google-cloud-spanner-admin-instance-v1", "~> 1.6" - gem.add_dependency "google-cloud-spanner-v1", "~> 1.6" + gem.add_dependency "google-cloud-spanner-v1", "~> 1.12" end diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index 29295180..319c391d 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -280,6 +280,124 @@ def delete table, keys = [] keys end + ## + # Sends a message to a queue. + # + # All changes are accumulated in memory until the block passed to + # {Client#commit} completes. + # + # @param [String] queue The name of the queue. + # @param [Object, Array] key A single key or array of composite key parts for the message. + # @param [String, IO, StringIO, Hash, Google::Protobuf::MessageExts] payload + # The message payload. It is converted the same way as column values + # (see {#upsert}), so its type must match the queue's payload column, + # such as a `String` for `STRING` or a `StringIO` for `BYTES`: + # * `String` - Sent as text. + # * `IO`, `StringIO`, `File` - Sent as bytes (Base64-encoded). Ruby has + # no bytes type, so wrap binary data in a `StringIO`. + # * `Hash` - Serialized to a JSON string. + # * `Google::Protobuf::MessageExts` - Serialized and Base64-encoded. + # @param [Time, nil] deliver_time The time at which Spanner begins + # trying to deliver the message. Optional. If not set, the message is + # delivered immediately. A time in the past is replaced with a time + # closer to the current time. + # + # @return [Object, Array] The key of the message. + # + # @raise [ArgumentError] If the payload type is unsupported. + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # # OrderQueue has a STRING(MAX) payload column. + # db.commit do |c| + # c.send_message "OrderQueue", 101, "Order payload" + # end + # + # @example Send binary data to a queue with a BYTES payload column + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # db.commit do |c| + # c.send_message "LabelQueue", 101, StringIO.new(File.binread("label.png")) + # end + # + # @example Deliver the message in five minutes + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # db.commit do |c| + # c.send_message "OrderQueue", 101, "Order payload", + # deliver_time: Time.now + 300 + # end + # + def send_message queue, key, payload, deliver_time: nil + send_mutation = V1::Mutation::Send.new( + queue: queue, + key: key_list_value(key), + payload: Convert.object_to_grpc_value(payload), + deliver_time: Convert.time_to_timestamp(deliver_time) + ) + @mutations += [V1::Mutation.new(send: send_mutation)] + key + end + + ## + # Acknowledges a message in a queue, which removes it from the queue. + # + # All changes are accumulated in memory until the block passed to + # {Client#commit} completes. + # + # @param [String] queue The name of the queue. + # @param [Object, Array] key A single key or array of composite key parts to match. + # @param [Boolean] ignore_not_found If `true`, the ack succeeds even if + # the message doesn't exist. Optional. The default is `false`, which + # fails the commit with `NOT_FOUND` when the message doesn't exist. + # + # @return [Object, Array] The key of the message. + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # db.commit do |c| + # c.ack_message "OrderQueue", 101 + # end + # + # @example Ack a message whether or not it exists + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # db.commit do |c| + # c.ack_message "OrderQueue", 101, ignore_not_found: true + # end + # + def ack_message queue, key, ignore_not_found: false + ack_mutation = V1::Mutation::Ack.new( + queue: queue, + key: key_list_value(key), + ignore_not_found: ignore_not_found + ) + @mutations += [V1::Mutation.new(ack: ack_mutation)] + key + end + # Return the current mutations added to this `Spanner::Commit` object. # @private # @return [Array] @@ -325,6 +443,21 @@ def mutations_from_rows table, rows, type rows end + ## + # @private + # Generates a `ListValue` protobuf containing gRPC values for the given primary key. + # + # @param [Object, Array] key A scalar key, or an array of a composite key's parts. + # @return [Google::Protobuf::ListValue] + # + def key_list_value key + key = [key] unless key.is_a? Array + key_list = key.map do |k| + Convert.object_to_grpc_value k + end + Google::Protobuf::ListValue.new values: key_list + end + def key_set keys return V1::KeySet.new all: true if keys.nil? keys = [keys] unless keys.is_a? Array diff --git a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb index 7880959e..e6e0bb9f 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb @@ -1075,6 +1075,79 @@ def delete table, keys = [] @commit.delete table, keys end + ## + # Sends a message to a queue. + # + # All changes are accumulated in memory until the block passed to + # {Client#transaction} completes. + # + # @param [String] queue The name of the queue. + # @param [Object, Array] key A single key or array of composite key parts for the message. + # @param [String, IO, StringIO, Hash, Google::Protobuf::MessageExts] payload + # The message payload. It is converted the same way as column values + # (see {#upsert}), so its type must match the queue's payload column, + # such as a `String` for `STRING` or a `StringIO` for `BYTES`: + # * `String` - Sent as text. + # * `IO`, `StringIO`, `File` - Sent as bytes (Base64-encoded). Ruby has + # no bytes type, so wrap binary data in a `StringIO`. + # * `Hash` - Serialized to a JSON string. + # * `Google::Protobuf::MessageExts` - Serialized and Base64-encoded. + # @param [Time, nil] deliver_time The time at which Spanner begins + # trying to deliver the message. Optional. If not set, the message is + # delivered immediately. A time in the past is replaced with a time + # closer to the current time. + # + # @return [Object, Array] The key of the message. + # + # @raise [ArgumentError] If the payload type is unsupported. + # + # @example Send a message in the same transaction as the row it describes + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # db = spanner.client "my-instance", "my-database" + # + # # OrderQueue has a STRING(MAX) payload column. + # db.transaction do |tx| + # tx.insert "orders", [{ id: 101, status: "NEW" }] + # tx.send_message "OrderQueue", 101, "Order payload" + # end + # + def send_message queue, key, payload, deliver_time: nil + ensure_session! + @commit.send_message queue, key, payload, deliver_time: deliver_time + end + + ## + # Acknowledges a message in a queue, which removes it from the queue. + # + # All changes are accumulated in memory until the block passed to + # {Client#transaction} completes. + # + # @param [String] queue The name of the queue. + # @param [Object, Array] key A single key or array of composite key parts to match. + # @param [Boolean] ignore_not_found If `true`, the ack succeeds even if + # the message doesn't exist. Optional. The default is `false`, which + # fails the commit with `NOT_FOUND` when the message doesn't exist. + # + # @return [Object, Array] The key of the message. + # + # @example Ack a message in the same transaction as the write it caused + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # db = spanner.client "my-instance", "my-database" + # + # db.transaction do |tx| + # tx.update "orders", [{ id: 101, status: "PROCESSED" }] + # tx.ack_message "OrderQueue", 101 + # end + # + def ack_message queue, key, ignore_not_found: false + ensure_session! + @commit.ack_message queue, key, ignore_not_found: ignore_not_found + end + ## # @private # Returns the field names and types for a table. diff --git a/google-cloud-spanner/test/google/cloud/spanner/client/transaction_test.rb b/google-cloud-spanner/test/google/cloud/spanner/client/transaction_test.rb index 002e43a0..3ca56e3a 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/client/transaction_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/client/transaction_test.rb @@ -454,6 +454,53 @@ mock.verify end + it "sends and acks queue messages" do + mutations = [ + Google::Cloud::Spanner::V1::Mutation.new( + send: Google::Cloud::Spanner::V1::Mutation::Send.new( + queue: "TestQueue", + key: Google::Cloud::Spanner::Convert.object_to_grpc_value([101]).list_value, + payload: Google::Cloud::Spanner::Convert.object_to_grpc_value("Hello, Queues!") + ) + ), + Google::Cloud::Spanner::V1::Mutation.new( + ack: Google::Cloud::Spanner::V1::Mutation::Ack.new( + queue: "TestQueue", + key: Google::Cloud::Spanner::Convert.object_to_grpc_value([102]).list_value, + ignore_not_found: false + ) + ) + ] + + mock = Minitest::Mock.new + mock.expect :create_session, session_grpc, [{ database: database_path(instance_id, database_id), session: default_session_request }, default_options] + mock.expect :begin_transaction, transaction_grpc, [{ + session: session_grpc.name, + options: tx_opts, + request_options: nil, + mutation_key: mutations[0] + }, default_options] + + mock.expect :commit, commit_resp, [{ + session: session_grpc.name, + mutations: mutations, + transaction_id: transaction_id, + single_use_transaction: nil, precommit_token: nil, + request_options: nil + }, default_options] + spanner.service.mocked_service = mock + + timestamp = client.transaction do |tx| + tx.send_message "TestQueue", 101, "Hello, Queues!" + tx.ack_message "TestQueue", 102 + end + _(timestamp).must_equal commit_time + + shutdown_client! client + + mock.verify + end + it "deletes multiple rows of key ranges" do mutations = [ Google::Cloud::Spanner::V1::Mutation.new( diff --git a/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb new file mode 100644 index 00000000..fae81e2a --- /dev/null +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -0,0 +1,123 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "helper" + +describe Google::Cloud::Spanner::Commit, :queues, :mock_spanner do + let(:commit) { Google::Cloud::Spanner::Commit.new } + let(:deliver_time) { Time.now + 300 } + + it "builds a send mutation with scalar key" do + commit.send_message "TestQueue", 2, "Hello, Queues!" + + assert_equal 1, commit.mutations.count + mutation = commit.mutations.first + + assert mutation["send"] + assert_equal "TestQueue", mutation["send"].queue + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value([2]).list_value, mutation["send"].key + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value("Hello, Queues!"), mutation["send"].payload + assert_nil mutation["send"].deliver_time + end + + it "builds a send mutation with composite key" do + commit.send_message "TestQueue", ["tenant1", 2], "Hello, Queues!" + + assert_equal 1, commit.mutations.count + mutation = commit.mutations.first + + assert mutation["send"] + assert_equal "TestQueue", mutation["send"].queue + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value(["tenant1", 2]).list_value, mutation["send"].key + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value("Hello, Queues!"), mutation["send"].payload + assert_nil mutation["send"].deliver_time + end + + it "encodes StringIO payloads as Base64" do + raw_bytes = "\x00\x01\x02\xFF".b + commit.send_message "TestQueue", 1, StringIO.new(raw_bytes) + + assert_equal 1, commit.mutations.count + assert_equal Base64.strict_encode64(raw_bytes), commit.mutations.first["send"].payload.string_value + end + + it "encodes Hash payload as JSON string" do + hash_payload = { "event" => "user_created", "status" => "active" } + + commit.send_message "TestQueue", 1, hash_payload + + assert_equal 1, commit.mutations.count + mutation = commit.mutations.first + + assert_equal hash_payload.to_json, mutation["send"].payload.string_value + end + + it "raises ArgumentError for unsupported payload types" do + assert_raises ArgumentError do + commit.send_message "TestQueue", 1, Object.new + end + end + + it "sets deliver_time" do + commit.send_message "TestQueue", 1, "payload", deliver_time: deliver_time + + expected = Google::Cloud::Spanner::Convert.time_to_timestamp deliver_time + assert_equal expected, commit.mutations.first["send"].deliver_time + end + + it "raises ArgumentError for an unknown send_message keyword" do + assert_raises(ArgumentError) { commit.send_message "TestQueue", 1, "payload", deliver_at: deliver_time } + assert_empty commit.mutations + end + + it "returns the key from send_message" do + assert_equal 2, commit.send_message("TestQueue", 2, "payload") + assert_equal ["tenant1", 2], commit.send_message("TestQueue", ["tenant1", 2], "payload") + end + + it "builds an ack mutation with scalar and composite keys" do + commit.ack_message "TestQueue", 2 + commit.ack_message "TestQueue", ["tenant1", 2] + + assert_equal 2, commit.mutations.count + m1 = commit.mutations[0] + m2 = commit.mutations[1] + + assert m1.ack + assert_equal "TestQueue", m1.ack.queue + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value([2]).list_value, m1.ack.key + assert_equal false, m1.ack.ignore_not_found + + assert m2.ack + assert_equal "TestQueue", m2.ack.queue + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value(["tenant1", 2]).list_value, m2.ack.key + assert_equal false, m2.ack.ignore_not_found + end + + it "sets ignore_not_found" do + commit.ack_message "TestQueue", 1, ignore_not_found: true + + assert_equal true, commit.mutations.first.ack.ignore_not_found + end + + it "raises ArgumentError for an unknown ack_message keyword" do + assert_raises(ArgumentError) { commit.ack_message "TestQueue", 1, ignore_missing: true } + assert_empty commit.mutations + end + + it "returns the key from ack_message" do + assert_equal 2, commit.ack_message("TestQueue", 2) + assert_equal ["tenant1", 2], commit.ack_message("TestQueue", ["tenant1", 2]) + end +end diff --git a/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb b/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb new file mode 100644 index 00000000..8414c615 --- /dev/null +++ b/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb @@ -0,0 +1,80 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "helper" + +# Transaction#send_message and #ack_message delegate to the transaction's +# Commit, so payload and key conversion is covered in commit_queues_test.rb. +# These tests check that every argument reaches the transaction's mutations. +describe Google::Cloud::Spanner::Transaction, :queues, :mock_spanner do + let(:instance_id) { "my-instance-id" } + let(:database_id) { "my-database-id" } + let(:session_id) { "session123" } + let(:session_grpc) { Google::Cloud::Spanner::V1::Session.new name: session_path(instance_id, database_id, session_id) } + let(:session) { Google::Cloud::Spanner::Session.from_grpc session_grpc, spanner.service } + let(:transaction_id) { "tx789" } + let(:transaction_grpc) { Google::Cloud::Spanner::V1::Transaction.new id: transaction_id } + let(:transaction) { Google::Cloud::Spanner::Transaction.from_grpc transaction_grpc, session } + let(:deliver_time) { Time.now + 300 } + + it "adds a send mutation to the transaction" do + key = transaction.send_message "TestQueue", ["tenant1", 2], "Hello, Queues!" + + assert_equal ["tenant1", 2], key + assert_equal 1, transaction.mutations.count + mutation = transaction.mutations.first + + assert_equal "TestQueue", mutation["send"].queue + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value(["tenant1", 2]).list_value, mutation["send"].key + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value("Hello, Queues!"), mutation["send"].payload + assert_nil mutation["send"].deliver_time + end + + it "passes deliver_time to the send mutation" do + transaction.send_message "TestQueue", 1, "payload", deliver_time: deliver_time + + expected = Google::Cloud::Spanner::Convert.time_to_timestamp deliver_time + assert_equal expected, transaction.mutations.first["send"].deliver_time + end + + it "adds an ack mutation to the transaction" do + key = transaction.ack_message "TestQueue", ["tenant1", 2] + + assert_equal ["tenant1", 2], key + assert_equal 1, transaction.mutations.count + mutation = transaction.mutations.first + + assert_equal "TestQueue", mutation.ack.queue + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value(["tenant1", 2]).list_value, mutation.ack.key + assert_equal false, mutation.ack.ignore_not_found + end + + it "passes ignore_not_found to the ack mutation" do + transaction.ack_message "TestQueue", 1, ignore_not_found: true + + assert_equal true, transaction.mutations.first.ack.ignore_not_found + end + + it "raises when session is missing" do + transaction.session = nil + + assert_raises RuntimeError do + transaction.send_message "TestQueue", 1, "test" + end + + assert_raises RuntimeError do + transaction.ack_message "TestQueue", 1 + end + end +end