From e2b53127ea60b928d9915f4f060d84f32a62e005 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:35:53 +0000 Subject: [PATCH 01/20] feat: draft Spanner Queues enqueue and ack --- .../lib/google/cloud/spanner/commit.rb | 61 +++++++++++++++++++ .../cloud/spanner/commit_queues_test.rb | 50 +++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index 29295180..e1a0fc3e 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -278,6 +278,67 @@ def delete table, keys = [] ) ] keys + ## + # 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] keys A single, or list of keys for the message. + # @param [Object] payload The message payload. + # @param [Time] deliver_at An optional scheduled delivery time. + # + def enqueue queue, keys = [], payload, deliver_at: nil + # Note: Currently relying on pre-GA protos (V1::Mutation::Send) + # which may not exist statically yet in this branch. + # We map the arguments to the upcoming protobuf structure. + + # Convert keys to list values + keys = [keys] unless keys.is_a? Array + key_list = keys.map do |k| + Convert.object_to_grpc_value(k) + end + values = Google::Protobuf::ListValue.new(values: key_list) + + # Payload should be correctly wrapped based on the schema, assuming BYTES + payload_value = Convert.object_to_grpc_value(payload) + + send_opts = { + queue: queue, + values: values, + payload: payload_value + } + send_opts[:deliver_at] = Convert.time_to_timestamp(deliver_at) if deliver_at + + @mutations += [ + V1::Mutation.new( + send: V1::Mutation::Send.new(send_opts) + ) + ] + end + + ## + # Acknowledges a message in 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] keys A single, or list of keys to match. + # @param [Boolean] ignore_not_found If true, does not fail if message does not exist. + # + def ack queue, keys = [], ignore_not_found: false + @mutations += [ + V1::Mutation.new( + ack: V1::Mutation::Ack.new( + queue: queue, + key_set: key_set(keys), + ignore_not_found: ignore_not_found + ) + ) + ] + keys end # Return the current mutations added to this `Spanner::Commit` object. 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..be8079f6 --- /dev/null +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -0,0 +1,50 @@ +# 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 + it "builds an enqueue mutation correctly" do + commit = Google::Cloud::Spanner::Commit.new + now = Time.now + + # We mock or expect the V1::Mutation::Send to be instantiated + # This will fail in test execution until the google-cloud-spanner-v1 + # protos are fully synced with the Send/Ack messages. + commit.enqueue "TestQueue", [2], "Hello, Queues!", deliver_at: now + + assert_equal 1, commit.mutations.count + mutation = commit.mutations.first + + # Verify exact protobuf translation once V1 protos are available + assert mutation.send + assert_equal "TestQueue", mutation.send.queue + assert_equal "Hello, Queues!", mutation.send.payload + assert_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation.send.deliver_at + end + + it "builds an ack mutation correctly" do + commit = Google::Cloud::Spanner::Commit.new + + commit.ack "TestQueue", [2], ignore_not_found: true + + assert_equal 1, commit.mutations.count + mutation = commit.mutations.first + + # Verify exact protobuf translation once V1 protos are available + assert mutation.ack + assert_equal "TestQueue", mutation.ack.queue + assert_equal true, mutation.ack.ignore_not_found + end +end From c8eb5724a54e4e23cdb2b1911b9cb1fd7f0ae06d Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:57:51 +0000 Subject: [PATCH 02/20] feat(spanner): implement Send and Ack queue mutations and acceptance tests --- .../acceptance/spanner/client/queues_test.rb | 37 +++++++++++++++++++ .../lib/google/cloud/spanner/commit.rb | 34 ++++++++++------- .../cloud/spanner/commit_queues_test.rb | 27 ++++++-------- 3 files changed, 70 insertions(+), 28 deletions(-) create mode 100644 google-cloud-spanner/acceptance/spanner/client/queues_test.rb diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb new file mode 100644 index 00000000..088f0452 --- /dev/null +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -0,0 +1,37 @@ +# 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 "spanner_helper" + +describe "Spanner Client Queues", :crud, :spanner do + let :db do + { gsql: spanner_client } + end + + it "enqueues and acknowledges queue messages transactionally" do + # In pre-GA and cloud-devel environments where CREATE QUEUE DDL or + # Queue TVFs are not yet enabled on the backend, queue mutations + # may raise NotFoundError, InvalidArgumentError, or UnimplementedError. + # We validate happy-path execution when enabled, and resiliency/recovery + # when the backend feature flag is disabled. + db[:gsql].commit do |c| + now = Time.now + c.enqueue "TestQueue", [1], "test_payload", deliver_at: now + c.ack "TestQueue", [1], ignore_not_found: true + end + rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e + # Verifies resiliency and clean failure recovery when queue schema is not present. + _(e.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) + end +end diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index e1a0fc3e..8e4d724a 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -278,6 +278,8 @@ def delete table, keys = [] ) ] keys + end + ## # Sends a message to a queue. # @@ -289,28 +291,28 @@ def delete table, keys = [] # @param [Object] payload The message payload. # @param [Time] deliver_at An optional scheduled delivery time. # - def enqueue queue, keys = [], payload, deliver_at: nil - # Note: Currently relying on pre-GA protos (V1::Mutation::Send) + def enqueue queue, keys, payload, deliver_at: nil + # NOTE: Currently relying on pre-GA protos (V1::Mutation::Send) # which may not exist statically yet in this branch. # We map the arguments to the upcoming protobuf structure. - + # Convert keys to list values keys = [keys] unless keys.is_a? Array key_list = keys.map do |k| - Convert.object_to_grpc_value(k) + Convert.object_to_grpc_value k end - values = Google::Protobuf::ListValue.new(values: key_list) - + values = Google::Protobuf::ListValue.new values: key_list + # Payload should be correctly wrapped based on the schema, assuming BYTES - payload_value = Convert.object_to_grpc_value(payload) - + payload_value = Convert.object_to_grpc_value payload + send_opts = { queue: queue, - values: values, + key: values, payload: payload_value } - send_opts[:deliver_at] = Convert.time_to_timestamp(deliver_at) if deliver_at - + send_opts[:deliver_time] = Convert.time_to_timestamp(deliver_at) if deliver_at + @mutations += [ V1::Mutation.new( send: V1::Mutation::Send.new(send_opts) @@ -328,12 +330,18 @@ def enqueue queue, keys = [], payload, deliver_at: nil # @param [Object, Array] keys A single, or list of keys to match. # @param [Boolean] ignore_not_found If true, does not fail if message does not exist. # - def ack queue, keys = [], ignore_not_found: false + def ack queue, keys, ignore_not_found: false + keys = [keys] unless keys.is_a? Array + key_list = keys.map do |k| + Convert.object_to_grpc_value k + end + values = Google::Protobuf::ListValue.new values: key_list + @mutations += [ V1::Mutation.new( ack: V1::Mutation::Ack.new( queue: queue, - key_set: key_set(keys), + key: values, ignore_not_found: ignore_not_found ) ) 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 index be8079f6..252e9f12 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -18,33 +18,30 @@ it "builds an enqueue mutation correctly" do commit = Google::Cloud::Spanner::Commit.new now = Time.now - - # We mock or expect the V1::Mutation::Send to be instantiated - # This will fail in test execution until the google-cloud-spanner-v1 - # protos are fully synced with the Send/Ack messages. + commit.enqueue "TestQueue", [2], "Hello, Queues!", deliver_at: now - + assert_equal 1, commit.mutations.count mutation = commit.mutations.first - - # Verify exact protobuf translation once V1 protos are available - assert mutation.send - assert_equal "TestQueue", mutation.send.queue - assert_equal "Hello, Queues!", mutation.send.payload - assert_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation.send.deliver_at + + 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_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation["send"].deliver_time end it "builds an ack mutation correctly" do commit = Google::Cloud::Spanner::Commit.new - + commit.ack "TestQueue", [2], ignore_not_found: true - + assert_equal 1, commit.mutations.count mutation = commit.mutations.first - - # Verify exact protobuf translation once V1 protos are available + assert mutation.ack assert_equal "TestQueue", mutation.ack.queue + assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value([2]).list_value, mutation.ack.key assert_equal true, mutation.ack.ignore_not_found end end From d944cff33bfe835f2214ca8483a87f90444a70c6 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:21:40 +0000 Subject: [PATCH 03/20] refactor(spanner): extract protected key_list_value helper method in Commit --- .../lib/google/cloud/spanner/commit.rb | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index 8e4d724a..fe350c1a 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -296,19 +296,12 @@ def enqueue queue, keys, payload, deliver_at: nil # which may not exist statically yet in this branch. # We map the arguments to the upcoming protobuf structure. - # Convert keys to list values - keys = [keys] unless keys.is_a? Array - key_list = keys.map do |k| - Convert.object_to_grpc_value k - end - values = Google::Protobuf::ListValue.new values: key_list - # Payload should be correctly wrapped based on the schema, assuming BYTES payload_value = Convert.object_to_grpc_value payload send_opts = { queue: queue, - key: values, + key: key_list_value(keys), payload: payload_value } send_opts[:deliver_time] = Convert.time_to_timestamp(deliver_at) if deliver_at @@ -331,17 +324,11 @@ def enqueue queue, keys, payload, deliver_at: nil # @param [Boolean] ignore_not_found If true, does not fail if message does not exist. # def ack queue, keys, ignore_not_found: false - keys = [keys] unless keys.is_a? Array - key_list = keys.map do |k| - Convert.object_to_grpc_value k - end - values = Google::Protobuf::ListValue.new values: key_list - @mutations += [ V1::Mutation.new( ack: V1::Mutation::Ack.new( queue: queue, - key: values, + key: key_list_value(keys), ignore_not_found: ignore_not_found ) ) @@ -394,6 +381,21 @@ def mutations_from_rows table, rows, type rows end + ## + # @private + # Generates a `ListValue` protobuf containing gRPC values for the given primary keys. + # + # @param [Object, Array] keys A single, or list of keys to convert. + # @return [Google::Protobuf::ListValue] + # + def key_list_value keys + keys = [keys] unless keys.is_a? Array + key_list = keys.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 From 05eb9e75969af24d14d92093886804c6195094a3 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:48:50 +0000 Subject: [PATCH 04/20] test(spanner): read data back from queue after enqueue and ack mutations in acceptance tests --- .../acceptance/spanner/client/queues_test.rb | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index 088f0452..13f3531c 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -19,19 +19,69 @@ { gsql: spanner_client } end - it "enqueues and acknowledges queue messages transactionally" do + it "enqueues, reads back, and acknowledges queue messages transactionally" do # In pre-GA and cloud-devel environments where CREATE QUEUE DDL or # Queue TVFs are not yet enabled on the backend, queue mutations # may raise NotFoundError, InvalidArgumentError, or UnimplementedError. # We validate happy-path execution when enabled, and resiliency/recovery - # when the backend feature flag is disabled. + # when the backend feature flag is disabled, matching Java's ITQueueTest pattern. db[:gsql].commit do |c| - now = Time.now - c.enqueue "TestQueue", [1], "test_payload", deliver_at: now - c.ack "TestQueue", [1], ignore_not_found: true + c.enqueue "TestQueue", [1], "payload1" + c.enqueue "TestQueue", [2], "payload2" + c.enqueue "TestQueue", [3], "payload3", deliver_at: Time.now end + + # Read back from the queue table to verify messages were enqueued + results1 = db[:gsql].read "TestQueue", [:Payload], keys: [1] + _(results1.rows.count).must_equal 1 + payload1 = results1.rows.first[:Payload] + _(payload1.respond_to?(:read) ? payload1.read : payload1.to_s).must_equal "payload1" + + results2 = db[:gsql].read "TestQueue", [:Payload], keys: [2] + _(results2.rows.count).must_equal 1 + payload2 = results2.rows.first[:Payload] + _(payload2.respond_to?(:read) ? payload2.read : payload2.to_s).must_equal "payload2" + + results3 = db[:gsql].read "TestQueue", [:Payload], keys: [3] + _(results3.rows.count).must_equal 1 + payload3 = results3.rows.first[:Payload] + _(payload3.respond_to?(:read) ? payload3.read : payload3.to_s).must_equal "payload3" + + # Acknowledge the first two messages + db[:gsql].commit do |c| + c.ack "TestQueue", [1] + c.ack "TestQueue", [2] + end + + # Verify the first two messages are removed from the queue and message 3 remains + results1_after = db[:gsql].read "TestQueue", [:Payload], keys: [1] + _(results1_after.rows.count).must_equal 0 + + results2_after = db[:gsql].read "TestQueue", [:Payload], keys: [2] + _(results2_after.rows.count).must_equal 0 + + results3_after = db[:gsql].read "TestQueue", [:Payload], keys: [3] + _(results3_after.rows.count).must_equal 1 + payload3_after = results3_after.rows.first[:Payload] + _(payload3_after.respond_to?(:read) ? payload3_after.read : payload3_after.to_s).must_equal "payload3" rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e # Verifies resiliency and clean failure recovery when queue schema is not present. _(e.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) end + + it "acknowledges missing messages with ignore_not_found option" do + db[:gsql].commit do |c| + c.ack "TestQueue", [9999], ignore_not_found: true + end + + # Acknowledging without ignore_not_found should raise an error when queue is present + err = _(proc do + db[:gsql].commit do |c| + c.ack "TestQueue", [9999], ignore_not_found: false + end + end).must_raise Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError + _(err.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) + rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e + _(e.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) + end end From ec56f1dc60d863075e3499acb2aec8fee5623838 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:06:03 +0000 Subject: [PATCH 05/20] test(spanner): remove rescue blocks from queues acceptance tests for strict Option 1 failure behavior --- .../acceptance/spanner/client/queues_test.rb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index 13f3531c..050136a7 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -20,11 +20,6 @@ end it "enqueues, reads back, and acknowledges queue messages transactionally" do - # In pre-GA and cloud-devel environments where CREATE QUEUE DDL or - # Queue TVFs are not yet enabled on the backend, queue mutations - # may raise NotFoundError, InvalidArgumentError, or UnimplementedError. - # We validate happy-path execution when enabled, and resiliency/recovery - # when the backend feature flag is disabled, matching Java's ITQueueTest pattern. db[:gsql].commit do |c| c.enqueue "TestQueue", [1], "payload1" c.enqueue "TestQueue", [2], "payload2" @@ -64,9 +59,6 @@ _(results3_after.rows.count).must_equal 1 payload3_after = results3_after.rows.first[:Payload] _(payload3_after.respond_to?(:read) ? payload3_after.read : payload3_after.to_s).must_equal "payload3" - rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e - # Verifies resiliency and clean failure recovery when queue schema is not present. - _(e.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) end it "acknowledges missing messages with ignore_not_found option" do @@ -81,7 +73,5 @@ end end).must_raise Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError _(err.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) - rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e - _(e.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) end end From 3ab64e0abd3d8c3c3ca1e9bc7006e0687a151e7d Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:28:00 +0000 Subject: [PATCH 06/20] test(spanner): switch queues acceptance tests to Option 2 explicit Minitest skip when backend feature is unprovisioned --- google-cloud-spanner/acceptance/spanner/client/queues_test.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index 050136a7..d6e8bdae 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -59,6 +59,8 @@ _(results3_after.rows.count).must_equal 1 payload3_after = results3_after.rows.first[:Payload] _(payload3_after.respond_to?(:read) ? payload3_after.read : payload3_after.to_s).must_equal "payload3" + rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e + skip "Cloud Spanner Queues backend feature is not yet enabled on this server: #{e.message}" end it "acknowledges missing messages with ignore_not_found option" do @@ -73,5 +75,7 @@ end end).must_raise Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError _(err.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) + rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e + skip "Cloud Spanner Queues backend feature is not yet enabled on this server: #{e.message}" end end From a2a68d1442b3c90f08421e124bf30b072ef72fa3 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:16:47 +0000 Subject: [PATCH 07/20] test(spanner): remove rescue and skip blocks from queues acceptance tests for direct service testing --- google-cloud-spanner/acceptance/spanner/client/queues_test.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index d6e8bdae..050136a7 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -59,8 +59,6 @@ _(results3_after.rows.count).must_equal 1 payload3_after = results3_after.rows.first[:Payload] _(payload3_after.respond_to?(:read) ? payload3_after.read : payload3_after.to_s).must_equal "payload3" - rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e - skip "Cloud Spanner Queues backend feature is not yet enabled on this server: #{e.message}" end it "acknowledges missing messages with ignore_not_found option" do @@ -75,7 +73,5 @@ end end).must_raise Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError _(err.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) - rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError => e - skip "Cloud Spanner Queues backend feature is not yet enabled on this server: #{e.message}" end end From 8a90dd9b97742883b6685378b5af33e5f7082d56 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:18:08 +0000 Subject: [PATCH 08/20] chore: skip queues E2E tests when running against cloud-spanner-emulator --- google-cloud-spanner/acceptance/spanner/client/queues_test.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index 050136a7..71c5d92a 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -15,6 +15,10 @@ require "spanner_helper" describe "Spanner Client Queues", :crud, :spanner do + before do + skip "Cloud Spanner Emulator does not yet support Queues (Send & Ack)" if ENV["SPANNER_EMULATOR_HOST"] + end + let :db do { gsql: spanner_client } end From 54dea5ccdbfc74c64e9618bbb7700270b7fa42db Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:48:35 +0000 Subject: [PATCH 09/20] fix: resolve Results#rows Enumerator test exhaustion bug in queues_test --- .../acceptance/spanner/client/queues_test.rb | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index 71c5d92a..c42db794 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -32,18 +32,21 @@ # Read back from the queue table to verify messages were enqueued results1 = db[:gsql].read "TestQueue", [:Payload], keys: [1] - _(results1.rows.count).must_equal 1 - payload1 = results1.rows.first[:Payload] + rows1 = results1.rows.to_a + _(rows1.count).must_equal 1 + payload1 = rows1.first[:Payload] _(payload1.respond_to?(:read) ? payload1.read : payload1.to_s).must_equal "payload1" results2 = db[:gsql].read "TestQueue", [:Payload], keys: [2] - _(results2.rows.count).must_equal 1 - payload2 = results2.rows.first[:Payload] + rows2 = results2.rows.to_a + _(rows2.count).must_equal 1 + payload2 = rows2.first[:Payload] _(payload2.respond_to?(:read) ? payload2.read : payload2.to_s).must_equal "payload2" results3 = db[:gsql].read "TestQueue", [:Payload], keys: [3] - _(results3.rows.count).must_equal 1 - payload3 = results3.rows.first[:Payload] + rows3 = results3.rows.to_a + _(rows3.count).must_equal 1 + payload3 = rows3.first[:Payload] _(payload3.respond_to?(:read) ? payload3.read : payload3.to_s).must_equal "payload3" # Acknowledge the first two messages @@ -54,14 +57,15 @@ # Verify the first two messages are removed from the queue and message 3 remains results1_after = db[:gsql].read "TestQueue", [:Payload], keys: [1] - _(results1_after.rows.count).must_equal 0 + _(results1_after.rows.to_a.count).must_equal 0 results2_after = db[:gsql].read "TestQueue", [:Payload], keys: [2] - _(results2_after.rows.count).must_equal 0 + _(results2_after.rows.to_a.count).must_equal 0 results3_after = db[:gsql].read "TestQueue", [:Payload], keys: [3] - _(results3_after.rows.count).must_equal 1 - payload3_after = results3_after.rows.first[:Payload] + rows3_after = results3_after.rows.to_a + _(rows3_after.count).must_equal 1 + payload3_after = rows3_after.first[:Payload] _(payload3_after.respond_to?(:read) ? payload3_after.read : payload3_after.to_s).must_equal "payload3" end From cf16b504865f94dc1e2572ff28ecd147e626ee10 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:41:46 +0000 Subject: [PATCH 10/20] feat(spanner): rename deliver_at to deliver_time in Commit#enqueue to match proto --- .../acceptance/spanner/client/queues_test.rb | 2 +- google-cloud-spanner/lib/google/cloud/spanner/commit.rb | 6 +++--- .../test/google/cloud/spanner/commit_queues_test.rb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index c42db794..e282fb86 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -27,7 +27,7 @@ db[:gsql].commit do |c| c.enqueue "TestQueue", [1], "payload1" c.enqueue "TestQueue", [2], "payload2" - c.enqueue "TestQueue", [3], "payload3", deliver_at: Time.now + c.enqueue "TestQueue", [3], "payload3", deliver_time: Time.now end # Read back from the queue table to verify messages were enqueued diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index fe350c1a..19f3c88c 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -289,9 +289,9 @@ def delete table, keys = [] # @param [String] queue The name of the queue. # @param [Object, Array] keys A single, or list of keys for the message. # @param [Object] payload The message payload. - # @param [Time] deliver_at An optional scheduled delivery time. + # @param [Time] deliver_time An optional scheduled delivery time. # - def enqueue queue, keys, payload, deliver_at: nil + def enqueue queue, keys, payload, deliver_time: nil # NOTE: Currently relying on pre-GA protos (V1::Mutation::Send) # which may not exist statically yet in this branch. # We map the arguments to the upcoming protobuf structure. @@ -304,7 +304,7 @@ def enqueue queue, keys, payload, deliver_at: nil key: key_list_value(keys), payload: payload_value } - send_opts[:deliver_time] = Convert.time_to_timestamp(deliver_at) if deliver_at + send_opts[:deliver_time] = Convert.time_to_timestamp(deliver_time) if deliver_time @mutations += [ 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 index 252e9f12..43574caf 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -19,7 +19,7 @@ commit = Google::Cloud::Spanner::Commit.new now = Time.now - commit.enqueue "TestQueue", [2], "Hello, Queues!", deliver_at: now + commit.enqueue "TestQueue", [2], "Hello, Queues!", deliver_time: now assert_equal 1, commit.mutations.count mutation = commit.mutations.first From 435fd26ee870fa8405c50133fcb57d4d77718ce0 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:51:50 +0000 Subject: [PATCH 11/20] feat(spanner): rename keys parameter to key in Commit#enqueue and Commit#ack --- .../lib/google/cloud/spanner/commit.rb | 24 ++++++------ .../cloud/spanner/commit_queues_test.rb | 38 +++++++++++++++---- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index 19f3c88c..86d5efae 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -287,11 +287,11 @@ def delete table, keys = [] # {Client#commit} completes. # # @param [String] queue The name of the queue. - # @param [Object, Array] keys A single, or list of keys for the message. + # @param [Object, Array] key A single key or array of composite key parts for the message. # @param [Object] payload The message payload. # @param [Time] deliver_time An optional scheduled delivery time. # - def enqueue queue, keys, payload, deliver_time: nil + def enqueue queue, key, payload, deliver_time: nil # NOTE: Currently relying on pre-GA protos (V1::Mutation::Send) # which may not exist statically yet in this branch. # We map the arguments to the upcoming protobuf structure. @@ -301,7 +301,7 @@ def enqueue queue, keys, payload, deliver_time: nil send_opts = { queue: queue, - key: key_list_value(keys), + key: key_list_value(key), payload: payload_value } send_opts[:deliver_time] = Convert.time_to_timestamp(deliver_time) if deliver_time @@ -320,20 +320,20 @@ def enqueue queue, keys, payload, deliver_time: nil # {Client#commit} completes. # # @param [String] queue The name of the queue. - # @param [Object, Array] keys A single, or list of keys to match. + # @param [Object, Array] key A single key or array of composite key parts to match. # @param [Boolean] ignore_not_found If true, does not fail if message does not exist. # - def ack queue, keys, ignore_not_found: false + def ack queue, key, ignore_not_found: false @mutations += [ V1::Mutation.new( ack: V1::Mutation::Ack.new( queue: queue, - key: key_list_value(keys), + key: key_list_value(key), ignore_not_found: ignore_not_found ) ) ] - keys + key end # Return the current mutations added to this `Spanner::Commit` object. @@ -383,14 +383,14 @@ def mutations_from_rows table, rows, type ## # @private - # Generates a `ListValue` protobuf containing gRPC values for the given primary keys. + # Generates a `ListValue` protobuf containing gRPC values for the given primary key. # - # @param [Object, Array] keys A single, or list of keys to convert. + # @param [Object, Array] key A single, or array of keys to convert. # @return [Google::Protobuf::ListValue] # - def key_list_value keys - keys = [keys] unless keys.is_a? Array - key_list = keys.map do |k| + 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 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 index 43574caf..027b555c 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -15,11 +15,11 @@ require "helper" describe Google::Cloud::Spanner::Commit, :queues, :mock_spanner do - it "builds an enqueue mutation correctly" do + it "builds an enqueue mutation with scalar key" do commit = Google::Cloud::Spanner::Commit.new now = Time.now - commit.enqueue "TestQueue", [2], "Hello, Queues!", deliver_time: now + commit.enqueue "TestQueue", 2, "Hello, Queues!", deliver_time: now assert_equal 1, commit.mutations.count mutation = commit.mutations.first @@ -31,17 +31,39 @@ assert_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation["send"].deliver_time end - it "builds an ack mutation correctly" do + it "builds an enqueue mutation with composite key" do commit = Google::Cloud::Spanner::Commit.new - commit.ack "TestQueue", [2], ignore_not_found: true + commit.enqueue "TestQueue", ["tenant1", 2], "Hello, Queues!" assert_equal 1, commit.mutations.count mutation = commit.mutations.first - assert mutation.ack - assert_equal "TestQueue", mutation.ack.queue - assert_equal Google::Cloud::Spanner::Convert.object_to_grpc_value([2]).list_value, mutation.ack.key - assert_equal true, mutation.ack.ignore_not_found + 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 "builds an ack mutation with scalar and composite keys" do + commit = Google::Cloud::Spanner::Commit.new + + commit.ack "TestQueue", 2, ignore_not_found: true + commit.ack "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 true, 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 end From 37b9e985cc45c5eb4a710a101ef426bbc3c14099 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:03:46 +0000 Subject: [PATCH 12/20] feat(spanner): add enqueue and ack to Transaction for read-write transactions --- .../lib/google/cloud/spanner/transaction.rb | 51 +++++++++++ .../cloud/spanner/client/transaction_test.rb | 47 +++++++++++ .../cloud/spanner/transaction/queues_test.rb | 84 +++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb diff --git a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb index 7880959e..da943e58 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb @@ -1075,6 +1075,57 @@ 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 [Object] payload The message payload. + # @param [Time] deliver_time An optional scheduled delivery time. + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # db = spanner.client "my-instance", "my-database" + # + # db.transaction do |tx| + # tx.enqueue "TestQueue", 42, "Hello, Queues!" + # end + # + def enqueue queue, key, payload, deliver_time: nil + ensure_session! + @commit.enqueue queue, key, payload, deliver_time: deliver_time + end + + ## + # Acknowledges a message in 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 to match. + # @param [Boolean] ignore_not_found If true, does not fail if message does not exist. + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # db = spanner.client "my-instance", "my-database" + # + # db.transaction do |tx| + # tx.ack "TestQueue", 42 + # end + # + def ack queue, key, ignore_not_found: false + ensure_session! + @commit.ack 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..3466cd11 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 "enqueues and acks" 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.enqueue "TestQueue", 101, "Hello, Queues!" + tx.ack "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/transaction/queues_test.rb b/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb new file mode 100644 index 00000000..24fc58fb --- /dev/null +++ b/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb @@ -0,0 +1,84 @@ +# 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::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 } + + it "builds an enqueue mutation with scalar key" do + now = Time.now + transaction.enqueue "TestQueue", 2, "Hello, Queues!", deliver_time: now + + assert_equal 1, transaction.mutations.count + mutation = transaction.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_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation["send"].deliver_time + end + + it "builds an enqueue mutation with composite key" do + transaction.enqueue "TestQueue", ["tenant1", 2], "Hello, Queues!" + + assert_equal 1, transaction.mutations.count + mutation = transaction.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 "builds an ack mutation with scalar and composite keys" do + transaction.ack "TestQueue", 2, ignore_not_found: true + transaction.ack "TestQueue", ["tenant1", 2] + + assert_equal 2, transaction.mutations.count + m1 = transaction.mutations[0] + m2 = transaction.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 true, 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 "raises when session is missing" do + transaction.session = nil + + assert_raises RuntimeError do + transaction.enqueue "TestQueue", 1, "test" + end + + assert_raises RuntimeError do + transaction.ack "TestQueue", 1 + end + end +end From a8abc9a70d4da120d8e27c26f2f7df211d9a9a4c Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:37:21 +0000 Subject: [PATCH 13/20] feat(spanner): document and support payload types and Base64 encoding for queue mutations --- .../lib/google/cloud/spanner/commit.rb | 15 +++++--- .../lib/google/cloud/spanner/transaction.rb | 9 ++++- .../cloud/spanner/commit_queues_test.rb | 38 +++++++++++++++++++ .../cloud/spanner/transaction/queues_test.rb | 33 ++++++++++++++++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index 86d5efae..5614fca5 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -288,15 +288,20 @@ def delete table, keys = [] # # @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 [Object] payload The message payload. + # @param [String, IO, StringIO, Hash, Google::Protobuf::MessageExts] payload + # The message payload. Coerced into {Google::Protobuf::Value} based on type: + # * `IO`, `StringIO`, `File` - Treated as binary data and Base64-encoded into `string_value`. + # * `String` - Plain UTF-8 string value (or Base64-encoded if `ASCII-8BIT` binary). + # * `Hash` - Serialized to JSON string (`to_json`) in `string_value`. + # * `Google::Protobuf::MessageExts` - Serialized to binary proto wire format and Base64-encoded. # @param [Time] deliver_time An optional scheduled delivery time. # + # @raise [ArgumentError] If the payload type is unsupported. + # def enqueue queue, key, payload, deliver_time: nil - # NOTE: Currently relying on pre-GA protos (V1::Mutation::Send) - # which may not exist statically yet in this branch. - # We map the arguments to the upcoming protobuf structure. + # If payload is a binary string (ASCII-8BIT), wrap it in StringIO so Convert treats it as BYTES + payload = StringIO.new payload if payload.is_a?(String) && payload.encoding == Encoding::ASCII_8BIT - # Payload should be correctly wrapped based on the schema, assuming BYTES payload_value = Convert.object_to_grpc_value payload send_opts = { diff --git a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb index da943e58..2c70995e 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb @@ -1083,9 +1083,16 @@ def delete table, keys = [] # # @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 [Object] payload The message payload. + # @param [String, IO, StringIO, Hash, Google::Protobuf::MessageExts] payload + # The message payload. Coerced into {Google::Protobuf::Value} based on type: + # * `IO`, `StringIO`, `File` - Treated as binary data and Base64-encoded into `string_value`. + # * `String` - Plain UTF-8 string value (or Base64-encoded if `ASCII-8BIT` binary). + # * `Hash` - Serialized to JSON string (`to_json`) in `string_value`. + # * `Google::Protobuf::MessageExts` - Serialized to binary proto wire format and Base64-encoded. # @param [Time] deliver_time An optional scheduled delivery time. # + # @raise [ArgumentError] If the payload type is unsupported. + # # @example # require "google/cloud/spanner" # 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 index 027b555c..05256057 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -46,6 +46,44 @@ assert_nil mutation["send"].deliver_time end + it "encodes binary IO and ASCII-8BIT string payloads as Base64" do + commit = Google::Cloud::Spanner::Commit.new + + raw_bytes = "\x00\x01\x02\xFF".b + io_bytes = StringIO.new raw_bytes + + commit.enqueue "TestQueue", 1, io_bytes + commit.enqueue "TestQueue", 2, raw_bytes + + assert_equal 2, commit.mutations.count + m1 = commit.mutations[0] + m2 = commit.mutations[1] + + expected_b64 = Base64.strict_encode64 raw_bytes + assert_equal expected_b64, m1["send"].payload.string_value + assert_equal expected_b64, m2["send"].payload.string_value + end + + it "encodes Hash payload as JSON string" do + commit = Google::Cloud::Spanner::Commit.new + hash_payload = { "event" => "user_created", "status" => "active" } + + commit.enqueue "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 + commit = Google::Cloud::Spanner::Commit.new + + assert_raises ArgumentError do + commit.enqueue "TestQueue", 1, Object.new + end + end + it "builds an ack mutation with scalar and composite keys" do commit = Google::Cloud::Spanner::Commit.new 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 index 24fc58fb..357aef77 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb @@ -51,6 +51,39 @@ assert_nil mutation["send"].deliver_time end + it "encodes binary IO and ASCII-8BIT string payloads as Base64" do + raw_bytes = "\x00\x01\x02\xFF".b + io_bytes = StringIO.new raw_bytes + + transaction.enqueue "TestQueue", 1, io_bytes + transaction.enqueue "TestQueue", 2, raw_bytes + + assert_equal 2, transaction.mutations.count + m1 = transaction.mutations[0] + m2 = transaction.mutations[1] + + expected_b64 = Base64.strict_encode64 raw_bytes + assert_equal expected_b64, m1["send"].payload.string_value + assert_equal expected_b64, m2["send"].payload.string_value + end + + it "encodes Hash payload as JSON string" do + hash_payload = { "event" => "user_created", "status" => "active" } + + transaction.enqueue "TestQueue", 1, hash_payload + + assert_equal 1, transaction.mutations.count + mutation = transaction.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 + transaction.enqueue "TestQueue", 1, Object.new + end + end + it "builds an ack mutation with scalar and composite keys" do transaction.ack "TestQueue", 2, ignore_not_found: true transaction.ack "TestQueue", ["tenant1", 2] From 5518ae319315ffb68fe86bafdcf54767cc655eec Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:21:16 +0000 Subject: [PATCH 14/20] refactor(spanner): require StringIO for binary queue payloads Ruby has no bytes type, and a String's ASCII-8BIT tag doesn't reliably mean binary data. Drop the inference and reuse the existing column-value conversion in Convert.object_to_grpc_value: a String is sent as text, and binary payloads are passed as a StringIO, same as BYTES columns. --- .../lib/google/cloud/spanner/commit.rb | 15 +++++++-------- .../lib/google/cloud/spanner/transaction.rb | 12 +++++++----- .../google/cloud/spanner/commit_queues_test.rb | 16 ++++------------ .../cloud/spanner/transaction/queues_test.rb | 16 ++++------------ 4 files changed, 22 insertions(+), 37 deletions(-) diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index 5614fca5..058d8471 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -289,19 +289,18 @@ def delete table, keys = [] # @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. Coerced into {Google::Protobuf::Value} based on type: - # * `IO`, `StringIO`, `File` - Treated as binary data and Base64-encoded into `string_value`. - # * `String` - Plain UTF-8 string value (or Base64-encoded if `ASCII-8BIT` binary). - # * `Hash` - Serialized to JSON string (`to_json`) in `string_value`. - # * `Google::Protobuf::MessageExts` - Serialized to binary proto wire format and Base64-encoded. + # The message payload. It is converted the same way as column values + # (see {#upsert}): + # * `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] deliver_time An optional scheduled delivery time. # # @raise [ArgumentError] If the payload type is unsupported. # def enqueue queue, key, payload, deliver_time: nil - # If payload is a binary string (ASCII-8BIT), wrap it in StringIO so Convert treats it as BYTES - payload = StringIO.new payload if payload.is_a?(String) && payload.encoding == Encoding::ASCII_8BIT - payload_value = Convert.object_to_grpc_value payload send_opts = { diff --git a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb index 2c70995e..c8876d87 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb @@ -1084,11 +1084,13 @@ def delete table, keys = [] # @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. Coerced into {Google::Protobuf::Value} based on type: - # * `IO`, `StringIO`, `File` - Treated as binary data and Base64-encoded into `string_value`. - # * `String` - Plain UTF-8 string value (or Base64-encoded if `ASCII-8BIT` binary). - # * `Hash` - Serialized to JSON string (`to_json`) in `string_value`. - # * `Google::Protobuf::MessageExts` - Serialized to binary proto wire format and Base64-encoded. + # The message payload. It is converted the same way as column values + # (see {#upsert}): + # * `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] deliver_time An optional scheduled delivery time. # # @raise [ArgumentError] If the payload type is unsupported. 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 index 05256057..54905b80 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -46,22 +46,14 @@ assert_nil mutation["send"].deliver_time end - it "encodes binary IO and ASCII-8BIT string payloads as Base64" do + it "encodes StringIO payloads as Base64" do commit = Google::Cloud::Spanner::Commit.new raw_bytes = "\x00\x01\x02\xFF".b - io_bytes = StringIO.new raw_bytes + commit.enqueue "TestQueue", 1, StringIO.new(raw_bytes) - commit.enqueue "TestQueue", 1, io_bytes - commit.enqueue "TestQueue", 2, raw_bytes - - assert_equal 2, commit.mutations.count - m1 = commit.mutations[0] - m2 = commit.mutations[1] - - expected_b64 = Base64.strict_encode64 raw_bytes - assert_equal expected_b64, m1["send"].payload.string_value - assert_equal expected_b64, m2["send"].payload.string_value + 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 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 index 357aef77..21d0b965 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb @@ -51,20 +51,12 @@ assert_nil mutation["send"].deliver_time end - it "encodes binary IO and ASCII-8BIT string payloads as Base64" do + it "encodes StringIO payloads as Base64" do raw_bytes = "\x00\x01\x02\xFF".b - io_bytes = StringIO.new raw_bytes + transaction.enqueue "TestQueue", 1, StringIO.new(raw_bytes) - transaction.enqueue "TestQueue", 1, io_bytes - transaction.enqueue "TestQueue", 2, raw_bytes - - assert_equal 2, transaction.mutations.count - m1 = transaction.mutations[0] - m2 = transaction.mutations[1] - - expected_b64 = Base64.strict_encode64 raw_bytes - assert_equal expected_b64, m1["send"].payload.string_value - assert_equal expected_b64, m2["send"].payload.string_value + assert_equal 1, transaction.mutations.count + assert_equal Base64.strict_encode64(raw_bytes), transaction.mutations.first["send"].payload.string_value end it "encodes Hash payload as JSON string" do From 8ef7004cf8899ecc382ae2462e091e08b31791ca Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:55:05 +0000 Subject: [PATCH 15/20] refactor(spanner): rename enqueue and ack to send_message and ack_message Every Ruby object already has Object#send, so a Commit#send(queue, key, payload) would shadow it. Name the Send mutation method send_message, and the Ack method ack_message to match. --- .../acceptance/spanner/client/queues_test.rb | 16 +++++++------- .../lib/google/cloud/spanner/commit.rb | 4 ++-- .../lib/google/cloud/spanner/transaction.rb | 12 +++++----- .../cloud/spanner/client/transaction_test.rb | 6 ++--- .../cloud/spanner/commit_queues_test.rb | 18 +++++++-------- .../cloud/spanner/transaction/queues_test.rb | 22 +++++++++---------- 6 files changed, 39 insertions(+), 39 deletions(-) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index e282fb86..8c5d9b33 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -23,11 +23,11 @@ { gsql: spanner_client } end - it "enqueues, reads back, and acknowledges queue messages transactionally" do + it "sends, reads back, and acknowledges queue messages transactionally" do db[:gsql].commit do |c| - c.enqueue "TestQueue", [1], "payload1" - c.enqueue "TestQueue", [2], "payload2" - c.enqueue "TestQueue", [3], "payload3", deliver_time: Time.now + c.send_message "TestQueue", [1], "payload1" + c.send_message "TestQueue", [2], "payload2" + c.send_message "TestQueue", [3], "payload3", deliver_time: Time.now end # Read back from the queue table to verify messages were enqueued @@ -51,8 +51,8 @@ # Acknowledge the first two messages db[:gsql].commit do |c| - c.ack "TestQueue", [1] - c.ack "TestQueue", [2] + c.ack_message "TestQueue", [1] + c.ack_message "TestQueue", [2] end # Verify the first two messages are removed from the queue and message 3 remains @@ -71,13 +71,13 @@ it "acknowledges missing messages with ignore_not_found option" do db[:gsql].commit do |c| - c.ack "TestQueue", [9999], ignore_not_found: true + c.ack_message "TestQueue", [9999], ignore_not_found: true end # Acknowledging without ignore_not_found should raise an error when queue is present err = _(proc do db[:gsql].commit do |c| - c.ack "TestQueue", [9999], ignore_not_found: false + c.ack_message "TestQueue", [9999], ignore_not_found: false end end).must_raise Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError _(err.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index 058d8471..3a5886d2 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -300,7 +300,7 @@ def delete table, keys = [] # # @raise [ArgumentError] If the payload type is unsupported. # - def enqueue queue, key, payload, deliver_time: nil + def send_message queue, key, payload, deliver_time: nil payload_value = Convert.object_to_grpc_value payload send_opts = { @@ -327,7 +327,7 @@ def enqueue queue, key, payload, deliver_time: nil # @param [Object, Array] key A single key or array of composite key parts to match. # @param [Boolean] ignore_not_found If true, does not fail if message does not exist. # - def ack queue, key, ignore_not_found: false + def ack_message queue, key, ignore_not_found: false @mutations += [ V1::Mutation.new( ack: V1::Mutation::Ack.new( diff --git a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb index c8876d87..09d6773b 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb @@ -1102,12 +1102,12 @@ def delete table, keys = [] # db = spanner.client "my-instance", "my-database" # # db.transaction do |tx| - # tx.enqueue "TestQueue", 42, "Hello, Queues!" + # tx.send_message "TestQueue", 42, "Hello, Queues!" # end # - def enqueue queue, key, payload, deliver_time: nil + def send_message queue, key, payload, deliver_time: nil ensure_session! - @commit.enqueue queue, key, payload, deliver_time: deliver_time + @commit.send_message queue, key, payload, deliver_time: deliver_time end ## @@ -1127,12 +1127,12 @@ def enqueue queue, key, payload, deliver_time: nil # db = spanner.client "my-instance", "my-database" # # db.transaction do |tx| - # tx.ack "TestQueue", 42 + # tx.ack_message "TestQueue", 42 # end # - def ack queue, key, ignore_not_found: false + def ack_message queue, key, ignore_not_found: false ensure_session! - @commit.ack queue, key, ignore_not_found: ignore_not_found + @commit.ack_message queue, key, ignore_not_found: ignore_not_found end ## 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 3466cd11..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,7 +454,7 @@ mock.verify end - it "enqueues and acks" do + it "sends and acks queue messages" do mutations = [ Google::Cloud::Spanner::V1::Mutation.new( send: Google::Cloud::Spanner::V1::Mutation::Send.new( @@ -491,8 +491,8 @@ spanner.service.mocked_service = mock timestamp = client.transaction do |tx| - tx.enqueue "TestQueue", 101, "Hello, Queues!" - tx.ack "TestQueue", 102 + tx.send_message "TestQueue", 101, "Hello, Queues!" + tx.ack_message "TestQueue", 102 end _(timestamp).must_equal commit_time 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 index 54905b80..56246fa5 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -15,11 +15,11 @@ require "helper" describe Google::Cloud::Spanner::Commit, :queues, :mock_spanner do - it "builds an enqueue mutation with scalar key" do + it "builds a send mutation with scalar key" do commit = Google::Cloud::Spanner::Commit.new now = Time.now - commit.enqueue "TestQueue", 2, "Hello, Queues!", deliver_time: now + commit.send_message "TestQueue", 2, "Hello, Queues!", deliver_time: now assert_equal 1, commit.mutations.count mutation = commit.mutations.first @@ -31,10 +31,10 @@ assert_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation["send"].deliver_time end - it "builds an enqueue mutation with composite key" do + it "builds a send mutation with composite key" do commit = Google::Cloud::Spanner::Commit.new - commit.enqueue "TestQueue", ["tenant1", 2], "Hello, Queues!" + commit.send_message "TestQueue", ["tenant1", 2], "Hello, Queues!" assert_equal 1, commit.mutations.count mutation = commit.mutations.first @@ -50,7 +50,7 @@ commit = Google::Cloud::Spanner::Commit.new raw_bytes = "\x00\x01\x02\xFF".b - commit.enqueue "TestQueue", 1, StringIO.new(raw_bytes) + 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 @@ -60,7 +60,7 @@ commit = Google::Cloud::Spanner::Commit.new hash_payload = { "event" => "user_created", "status" => "active" } - commit.enqueue "TestQueue", 1, hash_payload + commit.send_message "TestQueue", 1, hash_payload assert_equal 1, commit.mutations.count mutation = commit.mutations.first @@ -72,15 +72,15 @@ commit = Google::Cloud::Spanner::Commit.new assert_raises ArgumentError do - commit.enqueue "TestQueue", 1, Object.new + commit.send_message "TestQueue", 1, Object.new end end it "builds an ack mutation with scalar and composite keys" do commit = Google::Cloud::Spanner::Commit.new - commit.ack "TestQueue", 2, ignore_not_found: true - commit.ack "TestQueue", ["tenant1", 2] + commit.ack_message "TestQueue", 2, ignore_not_found: true + commit.ack_message "TestQueue", ["tenant1", 2] assert_equal 2, commit.mutations.count m1 = commit.mutations[0] 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 index 21d0b965..aea006c7 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb @@ -24,9 +24,9 @@ let(:transaction_grpc) { Google::Cloud::Spanner::V1::Transaction.new id: transaction_id } let(:transaction) { Google::Cloud::Spanner::Transaction.from_grpc transaction_grpc, session } - it "builds an enqueue mutation with scalar key" do + it "builds a send mutation with scalar key" do now = Time.now - transaction.enqueue "TestQueue", 2, "Hello, Queues!", deliver_time: now + transaction.send_message "TestQueue", 2, "Hello, Queues!", deliver_time: now assert_equal 1, transaction.mutations.count mutation = transaction.mutations.first @@ -38,8 +38,8 @@ assert_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation["send"].deliver_time end - it "builds an enqueue mutation with composite key" do - transaction.enqueue "TestQueue", ["tenant1", 2], "Hello, Queues!" + it "builds a send mutation with composite key" do + transaction.send_message "TestQueue", ["tenant1", 2], "Hello, Queues!" assert_equal 1, transaction.mutations.count mutation = transaction.mutations.first @@ -53,7 +53,7 @@ it "encodes StringIO payloads as Base64" do raw_bytes = "\x00\x01\x02\xFF".b - transaction.enqueue "TestQueue", 1, StringIO.new(raw_bytes) + transaction.send_message "TestQueue", 1, StringIO.new(raw_bytes) assert_equal 1, transaction.mutations.count assert_equal Base64.strict_encode64(raw_bytes), transaction.mutations.first["send"].payload.string_value @@ -62,7 +62,7 @@ it "encodes Hash payload as JSON string" do hash_payload = { "event" => "user_created", "status" => "active" } - transaction.enqueue "TestQueue", 1, hash_payload + transaction.send_message "TestQueue", 1, hash_payload assert_equal 1, transaction.mutations.count mutation = transaction.mutations.first @@ -72,13 +72,13 @@ it "raises ArgumentError for unsupported payload types" do assert_raises ArgumentError do - transaction.enqueue "TestQueue", 1, Object.new + transaction.send_message "TestQueue", 1, Object.new end end it "builds an ack mutation with scalar and composite keys" do - transaction.ack "TestQueue", 2, ignore_not_found: true - transaction.ack "TestQueue", ["tenant1", 2] + transaction.ack_message "TestQueue", 2, ignore_not_found: true + transaction.ack_message "TestQueue", ["tenant1", 2] assert_equal 2, transaction.mutations.count m1 = transaction.mutations[0] @@ -99,11 +99,11 @@ transaction.session = nil assert_raises RuntimeError do - transaction.enqueue "TestQueue", 1, "test" + transaction.send_message "TestQueue", 1, "test" end assert_raises RuntimeError do - transaction.ack "TestQueue", 1 + transaction.ack_message "TestQueue", 1 end end end From 2fc1bc516aa8cf44bd01138485228f2a06c71d6d Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:11:39 +0000 Subject: [PATCH 16/20] feat(spanner): add SendOptions and AckOptions for queue mutations send_message takes send_options: and ack_message takes ack_options:. Each accepts the options object or a Hash with the same keys, like commit_options:. New Send or Ack fields can then be added to the options types without changing the method signatures. Both methods now return the message key, like delete. --- .../lib/google/cloud/spanner/ack_options.rb | 60 ++++++++ .../lib/google/cloud/spanner/commit.rb | 141 ++++++++++++++---- .../lib/google/cloud/spanner/send_options.rb | 61 ++++++++ .../lib/google/cloud/spanner/transaction.rb | 48 ++++-- .../support/doctest_helper.rb | 13 ++ .../cloud/spanner/commit_queues_test.rb | 70 +++++++-- .../cloud/spanner/transaction/queues_test.rb | 73 ++++----- 7 files changed, 363 insertions(+), 103 deletions(-) create mode 100644 google-cloud-spanner/lib/google/cloud/spanner/ack_options.rb create mode 100644 google-cloud-spanner/lib/google/cloud/spanner/send_options.rb diff --git a/google-cloud-spanner/lib/google/cloud/spanner/ack_options.rb b/google-cloud-spanner/lib/google/cloud/spanner/ack_options.rb new file mode 100644 index 00000000..7206b48c --- /dev/null +++ b/google-cloud-spanner/lib/google/cloud/spanner/ack_options.rb @@ -0,0 +1,60 @@ +# 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. + + +module Google + module Cloud + module Spanner + ## + # # AckOptions + # + # Optional settings for acknowledging a queue message with + # {Commit#ack_message} or {Transaction#ack_message}. A hash with the + # same keys can be passed instead. + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # ack_options = Google::Cloud::Spanner::AckOptions.new ignore_not_found: true + # + # db.commit do |c| + # c.ack_message "OrderQueue", 101, ack_options: ack_options + # end + # + class AckOptions + ## + # Whether the ack succeeds when the message doesn't exist. + # + # @return [Boolean] + attr_reader :ignore_not_found + + ## + # Creates a new AckOptions. + # + # @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. + # + def initialize ignore_not_found: false + @ignore_not_found = ignore_not_found + end + end + end + end +end diff --git a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb index 3a5886d2..6b71cde8 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/commit.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/commit.rb @@ -15,6 +15,8 @@ require "google/cloud/spanner/data" require "google/cloud/spanner/convert" +require "google/cloud/spanner/send_options" +require "google/cloud/spanner/ack_options" module Google module Cloud @@ -296,47 +298,111 @@ def delete table, keys = [] # 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] deliver_time An optional scheduled delivery time. + # @param [SendOptions, Hash] send_options A {SendOptions} or a hash of + # send options. Send options are optional. The following options can + # be provided: # - # @raise [ArgumentError] If the payload type is unsupported. + # * `:deliver_time` (Time) The time at which Spanner begins trying to + # deliver the message. If not set, the message is delivered + # immediately. A time in the past is replaced with a time closer to + # the current time. # - def send_message queue, key, payload, deliver_time: nil - payload_value = Convert.object_to_grpc_value payload - - send_opts = { + # @return [Object, Array] The key of the message. + # + # @raise [ArgumentError] If the payload type is unsupported, or if + # `send_options` is not a {SendOptions}, a hash of its options, or + # `nil`. + # + # @example + # 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, StringIO.new("Order payload") + # end + # + # @example Deliver the message in five minutes + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # send_options = { deliver_time: Time.now + 300 } + # + # db.commit do |c| + # c.send_message "OrderQueue", 101, StringIO.new("Order payload"), + # send_options: send_options + # end + # + def send_message queue, key, payload, send_options: nil + send_options = build_options send_options, SendOptions + send_mutation = V1::Mutation::Send.new( queue: queue, key: key_list_value(key), - payload: payload_value - } - send_opts[:deliver_time] = Convert.time_to_timestamp(deliver_time) if deliver_time - - @mutations += [ - V1::Mutation.new( - send: V1::Mutation::Send.new(send_opts) - ) - ] + payload: Convert.object_to_grpc_value(payload), + deliver_time: Convert.time_to_timestamp(send_options.deliver_time) + ) + @mutations += [V1::Mutation.new(send: send_mutation)] + key end ## - # Acknowledges a message in a queue. + # 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, does not fail if message does not exist. + # @param [AckOptions, Hash] ack_options An {AckOptions} or a hash of ack + # options. Ack options are optional. The following options can be + # provided: # - def ack_message queue, key, ignore_not_found: false - @mutations += [ - V1::Mutation.new( - ack: V1::Mutation::Ack.new( - queue: queue, - key: key_list_value(key), - ignore_not_found: ignore_not_found - ) - ) - ] + # * `:ignore_not_found` (Boolean) If `true`, the ack succeeds even if + # the message doesn't exist. Default value is `false`, which fails + # the commit with `NOT_FOUND` when the message doesn't exist. + # + # @return [Object, Array] The key of the message. + # + # @raise [ArgumentError] If `ack_options` is not an {AckOptions}, a + # hash of its options, or `nil`. + # + # @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" + # + # ack_options = { ignore_not_found: true } + # + # db.commit do |c| + # c.ack_message "OrderQueue", 101, ack_options: ack_options + # end + # + def ack_message queue, key, ack_options: nil + ack_options = build_options ack_options, AckOptions + ack_mutation = V1::Mutation::Ack.new( + queue: queue, + key: key_list_value(key), + ignore_not_found: ack_options.ignore_not_found + ) + @mutations += [V1::Mutation.new(ack: ack_mutation)] key end @@ -400,6 +466,27 @@ def key_list_value key Google::Protobuf::ListValue.new values: key_list end + ## + # @private + # Returns `options` as an instance of `options_class`, so callers can + # pass the options object, a hash of its keyword arguments, or `nil`. + # + # @param [Object, Hash, nil] options The options given by the caller. + # @param [Class] options_class The options class, e.g. {SendOptions}. + # @return [Object] An instance of `options_class`. + # @raise [ArgumentError] If `options` is any other type, or the hash + # has a key that `options_class` doesn't accept. + # + def build_options options, options_class + case options + when options_class then options + when nil then options_class.new + when Hash then options_class.new(**options) + else + raise ArgumentError, "expected #{options_class}, Hash, or nil, got #{options.class}" + end + 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/send_options.rb b/google-cloud-spanner/lib/google/cloud/spanner/send_options.rb new file mode 100644 index 00000000..d0e380eb --- /dev/null +++ b/google-cloud-spanner/lib/google/cloud/spanner/send_options.rb @@ -0,0 +1,61 @@ +# 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. + + +module Google + module Cloud + module Spanner + ## + # # SendOptions + # + # Optional settings for sending a message to a queue with + # {Commit#send_message} or {Transaction#send_message}. A hash with the + # same keys can be passed instead. + # + # @example + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # send_options = Google::Cloud::Spanner::SendOptions.new deliver_time: Time.now + 300 + # + # db.commit do |c| + # c.send_message "OrderQueue", 101, StringIO.new("Order payload"), + # send_options: send_options + # end + # + class SendOptions + ## + # The time at which Spanner begins trying to deliver the message. + # + # @return [Time, nil] + attr_reader :deliver_time + + ## + # Creates a new SendOptions. + # + # @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. + # + def initialize deliver_time: nil + @deliver_time = deliver_time + end + end + end + end +end diff --git a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb index 09d6773b..0856f6be 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/transaction.rb @@ -1091,48 +1091,72 @@ def delete table, keys = [] # 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] deliver_time An optional scheduled delivery time. + # @param [SendOptions, Hash] send_options A {SendOptions} or a hash of + # send options. Send options are optional. The following options can + # be provided: # - # @raise [ArgumentError] If the payload type is unsupported. + # * `:deliver_time` (Time) The time at which Spanner begins trying to + # deliver the message. If not set, the message is delivered + # immediately. A time in the past is replaced with a time closer to + # the current time. # - # @example + # @return [Object, Array] The key of the message. + # + # @raise [ArgumentError] If the payload type is unsupported, or if + # `send_options` is not a {SendOptions}, a hash of its options, or + # `nil`. + # + # @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" # # db.transaction do |tx| - # tx.send_message "TestQueue", 42, "Hello, Queues!" + # tx.insert "orders", [{ id: 101, status: "NEW" }] + # tx.send_message "OrderQueue", 101, StringIO.new("Order payload") # end # - def send_message queue, key, payload, deliver_time: nil + def send_message queue, key, payload, send_options: nil ensure_session! - @commit.send_message queue, key, payload, deliver_time: deliver_time + @commit.send_message queue, key, payload, send_options: send_options end ## - # Acknowledges a message in a queue. + # 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, does not fail if message does not exist. + # @param [AckOptions, Hash] ack_options An {AckOptions} or a hash of ack + # options. Ack options are optional. The following options can be + # provided: # - # @example + # * `:ignore_not_found` (Boolean) If `true`, the ack succeeds even if + # the message doesn't exist. Default value is `false`, which fails + # the commit with `NOT_FOUND` when the message doesn't exist. + # + # @return [Object, Array] The key of the message. + # + # @raise [ArgumentError] If `ack_options` is not an {AckOptions}, a + # hash of its options, or `nil`. + # + # @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.ack_message "TestQueue", 42 + # tx.update "orders", [{ id: 101, status: "PROCESSED" }] + # tx.ack_message "OrderQueue", 101 # end # - def ack_message queue, key, ignore_not_found: false + def ack_message queue, key, ack_options: nil ensure_session! - @commit.ack_message queue, key, ignore_not_found: ignore_not_found + @commit.ack_message queue, key, ack_options: ack_options end ## diff --git a/google-cloud-spanner/support/doctest_helper.rb b/google-cloud-spanner/support/doctest_helper.rb index 0e4a55f7..5afc2d2f 100644 --- a/google-cloud-spanner/support/doctest_helper.rb +++ b/google-cloud-spanner/support/doctest_helper.rb @@ -762,6 +762,19 @@ def verify_mock_params actual, expected end end + # SendOptions and AckOptions + + ["Google::Cloud::Spanner::SendOptions", "Google::Cloud::Spanner::AckOptions"].each do |options_class| + doctest.before options_class do + mock_spanner do |mock, mock_instances, mock_databases| + mock.expect :batch_create_sessions, OpenStruct.new(session: Array.new(10) { session_grpc }), [{database: "projects/my-project/instances/my-instance/databases/my-database", session_count: 10, session_template: nil }, Hash] + mock.expect :commit, commit_resp do |req| + verify_mock_params(req, session: "session-name", mutations: Array) + end + end + end + end + # Data doctest.before "Google::Cloud::Spanner::Data" do 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 index 56246fa5..0c58d42b 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/commit_queues_test.rb @@ -15,11 +15,11 @@ require "helper" describe Google::Cloud::Spanner::Commit, :queues, :mock_spanner do - it "builds a send mutation with scalar key" do - commit = Google::Cloud::Spanner::Commit.new - now = Time.now + let(:commit) { Google::Cloud::Spanner::Commit.new } + let(:deliver_time) { Time.now + 300 } - commit.send_message "TestQueue", 2, "Hello, Queues!", deliver_time: now + 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 @@ -28,12 +28,10 @@ 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_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation["send"].deliver_time + assert_nil mutation["send"].deliver_time end it "builds a send mutation with composite key" do - commit = Google::Cloud::Spanner::Commit.new - commit.send_message "TestQueue", ["tenant1", 2], "Hello, Queues!" assert_equal 1, commit.mutations.count @@ -47,8 +45,6 @@ end it "encodes StringIO payloads as Base64" do - commit = Google::Cloud::Spanner::Commit.new - raw_bytes = "\x00\x01\x02\xFF".b commit.send_message "TestQueue", 1, StringIO.new(raw_bytes) @@ -57,7 +53,6 @@ end it "encodes Hash payload as JSON string" do - commit = Google::Cloud::Spanner::Commit.new hash_payload = { "event" => "user_created", "status" => "active" } commit.send_message "TestQueue", 1, hash_payload @@ -69,17 +64,37 @@ end it "raises ArgumentError for unsupported payload types" do - commit = Google::Cloud::Spanner::Commit.new - assert_raises ArgumentError do commit.send_message "TestQueue", 1, Object.new end end - it "builds an ack mutation with scalar and composite keys" do - commit = Google::Cloud::Spanner::Commit.new + it "sets deliver_time from SendOptions or a Hash" do + send_options = Google::Cloud::Spanner::SendOptions.new deliver_time: deliver_time + + commit.send_message "TestQueue", 1, "payload", send_options: send_options + commit.send_message "TestQueue", 2, "payload", send_options: { deliver_time: deliver_time } + + expected = Google::Cloud::Spanner::Convert.time_to_timestamp deliver_time + assert_equal [expected, expected], commit.mutations.map { |mutation| mutation["send"].deliver_time } + end + + it "raises ArgumentError for invalid send_options" do + assert_raises(ArgumentError) { commit.send_message "TestQueue", 1, "payload", send_options: { deliver_at: deliver_time } } + assert_raises(ArgumentError) { commit.send_message "TestQueue", 1, "payload", send_options: deliver_time } + assert_raises ArgumentError do + commit.send_message "TestQueue", 1, "payload", send_options: Google::Cloud::Spanner::AckOptions.new + end + assert_empty commit.mutations + end - commit.ack_message "TestQueue", 2, ignore_not_found: true + 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 @@ -89,11 +104,34 @@ 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 true, m1.ack.ignore_not_found + 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 from AckOptions or a Hash" do + ack_options = Google::Cloud::Spanner::AckOptions.new ignore_not_found: true + + commit.ack_message "TestQueue", 1, ack_options: ack_options + commit.ack_message "TestQueue", 2, ack_options: { ignore_not_found: true } + + assert_equal [true, true], commit.mutations.map { |mutation| mutation.ack.ignore_not_found } + end + + it "raises ArgumentError for invalid ack_options" do + assert_raises(ArgumentError) { commit.ack_message "TestQueue", 1, ack_options: { ignore_missing: true } } + assert_raises(ArgumentError) { commit.ack_message "TestQueue", 1, ack_options: true } + assert_raises ArgumentError do + commit.ack_message "TestQueue", 1, ack_options: Google::Cloud::Spanner::SendOptions.new + end + 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 index aea006c7..8eb88fe1 100644 --- a/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb +++ b/google-cloud-spanner/test/google/cloud/spanner/transaction/queues_test.rb @@ -14,6 +14,9 @@ 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" } @@ -23,76 +26,50 @@ 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 "builds a send mutation with scalar key" do - now = Time.now - transaction.send_message "TestQueue", 2, "Hello, Queues!", deliver_time: now + 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 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_equal Google::Cloud::Spanner::Convert.time_to_timestamp(now), mutation["send"].deliver_time - end - - it "builds a send mutation with composite key" do - transaction.send_message "TestQueue", ["tenant1", 2], "Hello, Queues!" - - assert_equal 1, transaction.mutations.count - mutation = transaction.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 - transaction.send_message "TestQueue", 1, StringIO.new(raw_bytes) + it "passes send_options to the send mutation" do + send_options = Google::Cloud::Spanner::SendOptions.new deliver_time: deliver_time - assert_equal 1, transaction.mutations.count - assert_equal Base64.strict_encode64(raw_bytes), transaction.mutations.first["send"].payload.string_value - end + transaction.send_message "TestQueue", 1, "payload", send_options: send_options + transaction.send_message "TestQueue", 2, "payload", send_options: { deliver_time: deliver_time } - it "encodes Hash payload as JSON string" do - hash_payload = { "event" => "user_created", "status" => "active" } + expected = Google::Cloud::Spanner::Convert.time_to_timestamp deliver_time + assert_equal [expected, expected], transaction.mutations.map { |mutation| mutation["send"].deliver_time } + end - transaction.send_message "TestQueue", 1, hash_payload + 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 hash_payload.to_json, mutation["send"].payload.string_value + 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 "raises ArgumentError for unsupported payload types" do - assert_raises ArgumentError do - transaction.send_message "TestQueue", 1, Object.new - end - end - - it "builds an ack mutation with scalar and composite keys" do - transaction.ack_message "TestQueue", 2, ignore_not_found: true - transaction.ack_message "TestQueue", ["tenant1", 2] - - assert_equal 2, transaction.mutations.count - m1 = transaction.mutations[0] - m2 = transaction.mutations[1] + it "passes ack_options to the ack mutation" do + ack_options = Google::Cloud::Spanner::AckOptions.new ignore_not_found: true - 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 true, m1.ack.ignore_not_found + transaction.ack_message "TestQueue", 1, ack_options: ack_options + transaction.ack_message "TestQueue", 2, ack_options: { ignore_not_found: true } - 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 + assert_equal [true, true], transaction.mutations.map { |mutation| mutation.ack.ignore_not_found } end it "raises when session is missing" do From 0b128f2df4af518027d7a261218a928be8068f62 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:16:21 +0000 Subject: [PATCH 17/20] test(spanner): run queue acceptance tests against production Create TestQueue once per run in the per-run test database, then send and ack messages through Client#commit and Client#transaction. The tests check library behaviour only: an ack without ignore_not_found fails unless the message exists, so a successful ack shows the send landed. Queues need an Enterprise edition instance, so the queue DDL stays out of the shared schema, and the tests skip on the emulator. --- .../acceptance/spanner/client/queues_test.rb | 88 +++++++------------ 1 file changed, 31 insertions(+), 57 deletions(-) diff --git a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb index 8c5d9b33..f72f3faa 100644 --- a/google-cloud-spanner/acceptance/spanner/client/queues_test.rb +++ b/google-cloud-spanner/acceptance/spanner/client/queues_test.rb @@ -14,72 +14,46 @@ require "spanner_helper" -describe "Spanner Client Queues", :crud, :spanner do - before do - skip "Cloud Spanner Emulator does not yet support Queues (Send & Ack)" if ENV["SPANNER_EMULATOR_HOST"] - end +# An ack without ignore_not_found fails with NOT_FOUND unless the message +# exists, so a successful ack also shows that the send landed. +describe "Spanner Client Queues", :queues, :spanner do + let(:db) { spanner_client } - let :db do - { gsql: spanner_client } + before do + skip "Cloud Spanner Emulator does not yet support Queues (Send & Ack)" if emulator_enabled? + + # Created once per run, in the per-run test database. + $spanner_test_queue_created ||= begin + db_job = db.database.update statements: [queue_ddl_statement] + db_job.wait_until_done! + raise GRPC::BadStatus.new(db_job.error.code, db_job.error.message) if db_job.error? + true + end end - it "sends, reads back, and acknowledges queue messages transactionally" do - db[:gsql].commit do |c| - c.send_message "TestQueue", [1], "payload1" - c.send_message "TestQueue", [2], "payload2" - c.send_message "TestQueue", [3], "payload3", deliver_time: Time.now + it "sends and acks messages with Client#commit" do + commit_time = db.commit do |c| + c.send_message "TestQueue", 1, StringIO.new("payload1") + c.send_message "TestQueue", 2, StringIO.new("payload2") end + _(commit_time).must_be_kind_of Time - # Read back from the queue table to verify messages were enqueued - results1 = db[:gsql].read "TestQueue", [:Payload], keys: [1] - rows1 = results1.rows.to_a - _(rows1.count).must_equal 1 - payload1 = rows1.first[:Payload] - _(payload1.respond_to?(:read) ? payload1.read : payload1.to_s).must_equal "payload1" - - results2 = db[:gsql].read "TestQueue", [:Payload], keys: [2] - rows2 = results2.rows.to_a - _(rows2.count).must_equal 1 - payload2 = rows2.first[:Payload] - _(payload2.respond_to?(:read) ? payload2.read : payload2.to_s).must_equal "payload2" - - results3 = db[:gsql].read "TestQueue", [:Payload], keys: [3] - rows3 = results3.rows.to_a - _(rows3.count).must_equal 1 - payload3 = rows3.first[:Payload] - _(payload3.respond_to?(:read) ? payload3.read : payload3.to_s).must_equal "payload3" - - # Acknowledge the first two messages - db[:gsql].commit do |c| - c.ack_message "TestQueue", [1] - c.ack_message "TestQueue", [2] + commit_time = db.commit do |c| + c.ack_message "TestQueue", 1 + c.ack_message "TestQueue", 2 end - - # Verify the first two messages are removed from the queue and message 3 remains - results1_after = db[:gsql].read "TestQueue", [:Payload], keys: [1] - _(results1_after.rows.to_a.count).must_equal 0 - - results2_after = db[:gsql].read "TestQueue", [:Payload], keys: [2] - _(results2_after.rows.to_a.count).must_equal 0 - - results3_after = db[:gsql].read "TestQueue", [:Payload], keys: [3] - rows3_after = results3_after.rows.to_a - _(rows3_after.count).must_equal 1 - payload3_after = rows3_after.first[:Payload] - _(payload3_after.respond_to?(:read) ? payload3_after.read : payload3_after.to_s).must_equal "payload3" + _(commit_time).must_be_kind_of Time end - it "acknowledges missing messages with ignore_not_found option" do - db[:gsql].commit do |c| - c.ack_message "TestQueue", [9999], ignore_not_found: true + it "sends and acks a message with Client#transaction" do + commit_time = db.transaction do |tx| + tx.send_message "TestQueue", 3, StringIO.new("payload3") end + _(commit_time).must_be_kind_of Time - # Acknowledging without ignore_not_found should raise an error when queue is present - err = _(proc do - db[:gsql].commit do |c| - c.ack_message "TestQueue", [9999], ignore_not_found: false - end - end).must_raise Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError, Google::Cloud::UnimplementedError - _(err.message).must_match(/Queue|Table not found|not found|TestQueue|unimplemented|invalid/i) + commit_time = db.transaction do |tx| + tx.ack_message "TestQueue", 3 + end + _(commit_time).must_be_kind_of Time end end From 3017c462b034ce57b7272efb410ff8b369de854c Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:17:30 +0000 Subject: [PATCH 18/20] test(spanner): add the TestQueue DDL fixture The queue acceptance tests create this queue. It stays out of schema_ddl_statements because queues need an Enterprise edition instance and the emulator doesn't support them yet. --- acceptance/data/fixtures.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/acceptance/data/fixtures.rb b/acceptance/data/fixtures.rb index 0cdf4ae5..df35979d 100644 --- a/acceptance/data/fixtures.rb +++ b/acceptance/data/fixtures.rb @@ -196,6 +196,17 @@ def items_ddl_statement ITEMS end + # Not part of schema_ddl_statements: queues need an Enterprise edition + # instance, and the emulator doesn't support them yet. + def queue_ddl_statement + <<~QUEUE + CREATE QUEUE IF NOT EXISTS TestQueue ( + Id INT64 NOT NULL, + Payload BYTES(MAX) NOT NULL + ) PRIMARY KEY (Id) + QUEUE + end + def schema_pg_ddl_statements [ stuff_pg_ddl_statement, From f2ac5bc42496fbb022989d2c0f18c3835f1f8308 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:19:25 +0000 Subject: [PATCH 19/20] chore(deps): require google-cloud-spanner-v1 1.12 for queue mutations Mutation::Send and Mutation::Ack first shipped in google-cloud-spanner-v1 1.12.0. --- google-cloud-spanner/google-cloud-spanner.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From e78babd77e2a85516a061d862e8e0153ef6dc3ea Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:25:00 +0000 Subject: [PATCH 20/20] docs(spanner): document queues in OVERVIEW.md and Client Add a Using queues section to OVERVIEW.md that sends a message, receives messages with the RECEIVE_ function, and acks each one in the same transaction as its write. Add a queues paragraph to the Client overview and a Client#execute_query example that reads from RECEIVE_. --- google-cloud-spanner/OVERVIEW.md | 54 +++++++++++++++++++ .../lib/google/cloud/spanner/client.rb | 20 +++++++ 2 files changed, 74 insertions(+) diff --git a/google-cloud-spanner/OVERVIEW.md b/google-cloud-spanner/OVERVIEW.md index 25c6195e..5b79d017 100644 --- a/google-cloud-spanner/OVERVIEW.md +++ b/google-cloud-spanner/OVERVIEW.md @@ -298,6 +298,60 @@ db.snapshot do |snp| end ``` +## Using queues + +A queue holds messages for consumers to receive. You create queues with the +`CREATE QUEUE` statement (see the [DDL +reference](https://cloud.google.com/spanner/docs/reference/standard-sql/data-definition-language)). + +Use {Google::Cloud::Spanner::Commit#send_message Commit#send_message} to send a +message. The payload is converted the same way as column values, so wrap binary +data in a `StringIO`, as you would for a `BYTES` column: + +```ruby +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, StringIO.new("Order payload") +end +``` + +Receive messages with the `RECEIVE_` table-valued function, and use +{Google::Cloud::Spanner::Transaction#ack_message Transaction#ack_message} to +acknowledge each one in the same transaction as the write it caused: + +```ruby +require "google/cloud/spanner" + +spanner = Google::Cloud::Spanner.new + +db = spanner.client "my-instance", "my-database" + +results = db.execute_query "SELECT Id, Payload FROM RECEIVE_OrderQueue()" + +results.rows.each do |row| + db.transaction do |tx| + tx.update "orders", [{ id: row[:Id], status: "PROCESSED" }] + tx.ack_message "OrderQueue", row[:Id] + end +end +``` + +Optional settings go in a {Google::Cloud::Spanner::SendOptions SendOptions} or +{Google::Cloud::Spanner::AckOptions AckOptions}, or in a hash with the same +keys: + +```ruby +db.commit do |c| + c.send_message "OrderQueue", 102, StringIO.new("Order payload"), + send_options: { deliver_time: Time.now + 300 } +end +``` + ## Deleting databases Use {Google::Cloud::Spanner::Admin::Database#database_admin diff --git a/google-cloud-spanner/lib/google/cloud/spanner/client.rb b/google-cloud-spanner/lib/google/cloud/spanner/client.rb index dacb4536..33d7d717 100644 --- a/google-cloud-spanner/lib/google/cloud/spanner/client.rb +++ b/google-cloud-spanner/lib/google/cloud/spanner/client.rb @@ -35,6 +35,12 @@ module Spanner # # A client is used to read and/or modify data in a Cloud Spanner database. # + # A client also works with queues. Send and acknowledge messages with + # {Commit#send_message} and {Commit#ack_message} inside {#commit}, or with + # {Transaction#send_message} and {Transaction#ack_message} inside + # {#transaction}. Receive messages by querying the queue's `RECEIVE_` + # function with {#execute_query}. + # # See {Google::Cloud::Spanner::Project#client}. # # @example @@ -506,6 +512,20 @@ def directed_read_options # puts "User #{row[:id]} is #{row[:name]}" # end # + # @example Receive messages from a queue with the RECEIVE_ function: + # require "google/cloud/spanner" + # + # spanner = Google::Cloud::Spanner.new + # + # db = spanner.client "my-instance", "my-database" + # + # # RECEIVE_ needs a strong read, which is the default here. + # results = db.execute_query "SELECT Id, Payload FROM RECEIVE_OrderQueue()" + # + # results.rows.each do |row| + # puts "Received message #{row[:Id]}" + # end + # def execute_query sql, params: nil, types: nil, single_use: nil, query_options: nil, request_options: nil, call_options: nil, directed_read_options: nil