activerecord-spanner-adapter 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/acceptance-tests-on-emulator.yaml +1 -1
  3. data/.github/workflows/acceptance-tests-on-production.yaml +4 -4
  4. data/.github/workflows/ci.yaml +1 -1
  5. data/.github/workflows/nightly-acceptance-tests-on-emulator.yaml +1 -1
  6. data/.github/workflows/nightly-acceptance-tests-on-production.yaml +3 -3
  7. data/.github/workflows/nightly-unit-tests.yaml +1 -1
  8. data/.github/workflows/rubocop.yaml +3 -3
  9. data/.github/workflows/samples.yaml +1 -1
  10. data/.release-please-manifest.json +1 -1
  11. data/CHANGELOG.md +9 -0
  12. data/Gemfile +2 -1
  13. data/acceptance/cases/tasks/database_tasks_test.rb +17 -4
  14. data/acceptance/cases/transactions/read_write_transactions_test.rb +29 -0
  15. data/examples/snippets/batch-dml/README.md +13 -0
  16. data/examples/snippets/batch-dml/Rakefile +13 -0
  17. data/examples/snippets/batch-dml/application.rb +54 -0
  18. data/examples/snippets/batch-dml/config/database.yml +11 -0
  19. data/examples/snippets/batch-dml/db/migrate/01_create_tables.rb +23 -0
  20. data/examples/snippets/batch-dml/db/seeds.rb +8 -0
  21. data/examples/snippets/batch-dml/models/album.rb +9 -0
  22. data/examples/snippets/batch-dml/models/singer.rb +9 -0
  23. data/examples/snippets/partitioned-dml/application.rb +31 -0
  24. data/lib/active_record/connection_adapters/spanner/database_statements.rb +79 -24
  25. data/lib/active_record/connection_adapters/spanner/errors/transaction_mutation_limit_exceeded_error.rb +25 -0
  26. data/lib/active_record/connection_adapters/spanner_adapter.rb +2 -1
  27. data/lib/activerecord_spanner_adapter/base.rb +4 -0
  28. data/lib/activerecord_spanner_adapter/connection.rb +124 -11
  29. data/lib/activerecord_spanner_adapter/transaction.rb +25 -4
  30. data/lib/activerecord_spanner_adapter/version.rb +1 -1
  31. data/lib/spanner_client_ext.rb +3 -2
  32. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35016d190d27c935b9a004570e326d7cdc75af064f877bd619334ea967cf4d7a
4
- data.tar.gz: 1cf91b818edc4d594fcf623f0a3218927406b47523931641fb6e844c88b578ff
3
+ metadata.gz: cb3f5f2106d42a64cf32616e119f188cc13a1b81acfa040765ae4b5cb2838cf1
4
+ data.tar.gz: 5ff65bbec67470ddc479f6ba985e9d31c0562848b9f0739e697ceb1d6715e3dc
5
5
  SHA512:
6
- metadata.gz: 96dfb48e59b278093e118006e393e03b8981fd370605664830a73ad09873ead412a726a838407595fb903194e64f7b368dc940a443d774847679c316742eefc3
7
- data.tar.gz: 5aa61e971ad3b21f9a9dee3476e7ce9fe5b52f080c2bd507e39cd2aa90719802d0ba5bcd024c3fa521feabc1e8756a249757dda5d3d432c67111ea3479d50e17
6
+ metadata.gz: 939b508009ff79969637045e526dab1a73ed2f9b979fc6812447b2b98e673dce42e2d55bbf924fc6b372c35e481a374f8fc556c853b7861b8344735549ca3521
7
+ data.tar.gz: f9c2dc8d41a7748a12daa8e342ae020c5109f793f62f0d2c506842d7030cac9d9578e169e356728f80e7c590e0a48bfa75fd05c235b121c25c9858e84cd25463
@@ -33,7 +33,7 @@ jobs:
33
33
  env:
34
34
  AR_VERSION: ${{ matrix.ar }}
35
35
  steps:
36
- - uses: actions/checkout@v4
36
+ - uses: actions/checkout@v6
37
37
  - name: Set up Ruby
38
38
  # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby
39
39
  # (see https://github.com/ruby/setup-ruby#versioning):
@@ -26,7 +26,7 @@ jobs:
26
26
  matrix:
27
27
  ruby: [3.3]
28
28
  steps:
29
- - uses: actions/checkout@v4
29
+ - uses: actions/checkout@v6
30
30
  - name: Set up Ruby
31
31
  # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby
32
32
  # (see https://github.com/ruby/setup-ruby#versioning):
@@ -35,17 +35,17 @@ jobs:
35
35
  bundler-cache: true
36
36
  ruby-version: ${{ matrix.ruby }}
37
37
  - name: Authenticate Google Cloud
38
- uses: google-github-actions/auth@v2
38
+ uses: google-github-actions/auth@v3
39
39
  with:
40
40
  credentials_json: ${{ secrets.GCP_SA_KEY }}
41
41
  - name: Setup GCloud
42
- uses: google-github-actions/setup-gcloud@v2
42
+ uses: google-github-actions/setup-gcloud@v3
43
43
  with:
44
44
  project_id: ${{ secrets.GCP_PROJECT_ID }}
45
45
  - name: Install dependencies
46
46
  run: bundle install
47
47
  - name: Run acceptance tests on production
48
- run: bundle exec rake acceptance\[,,,"exclude cases/migration"\]
48
+ run: bundle exec rake acceptance\[,,,"exclude cases/migration"\] TESTOPTS="-v"
49
49
  env:
50
50
  SPANNER_TEST_PROJECT: ${{ secrets.GCP_PROJECT_ID }}
51
51
  SPANNER_TEST_INSTANCE: ruby-activerecord-test
@@ -25,7 +25,7 @@ jobs:
25
25
  env:
26
26
  AR_VERSION: ${{ matrix.ar }}
27
27
  steps:
28
- - uses: actions/checkout@v4
28
+ - uses: actions/checkout@v6
29
29
  - name: Set up Ruby
30
30
  # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby
31
31
  # (see https://github.com/ruby/setup-ruby#versioning):
@@ -33,7 +33,7 @@ jobs:
33
33
  env:
34
34
  AR_VERSION: ${{ matrix.ar }}
35
35
  steps:
36
- - uses: actions/checkout@v4
36
+ - uses: actions/checkout@v6
37
37
  - name: Set up Ruby
38
38
  uses: ruby/setup-ruby@v1
39
39
  with:
@@ -12,7 +12,7 @@ jobs:
12
12
  matrix:
13
13
  ruby: [3.3]
14
14
  steps:
15
- - uses: actions/checkout@v4
15
+ - uses: actions/checkout@v6
16
16
  - name: Set up Ruby
17
17
  # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby
18
18
  # (see https://github.com/ruby/setup-ruby#versioning):
@@ -21,11 +21,11 @@ jobs:
21
21
  bundler-cache: true
22
22
  ruby-version: ${{ matrix.ruby }}
23
23
  - name: Authenticate Google Cloud
24
- uses: google-github-actions/auth@v2
24
+ uses: google-github-actions/auth@v3
25
25
  with:
26
26
  credentials_json: ${{ secrets.GCP_SA_KEY }}
27
27
  - name: Setup GCloud
28
- uses: google-github-actions/setup-gcloud@v2
28
+ uses: google-github-actions/setup-gcloud@v3
29
29
  with:
30
30
  project_id: ${{ secrets.GCP_PROJECT_ID }}
31
31
  - name: Install dependencies
@@ -26,7 +26,7 @@ jobs:
26
26
  env:
27
27
  AR_VERSION: ${{ matrix.ar }}
28
28
  steps:
29
- - uses: actions/checkout@v4
29
+ - uses: actions/checkout@v6
30
30
  - name: Set up Ruby
31
31
  uses: ruby/setup-ruby@v1
32
32
  with:
@@ -12,13 +12,13 @@ jobs:
12
12
  timeout-minutes: 10
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@v4
15
+ - uses: actions/checkout@v6
16
16
  - name: setup ruby
17
17
  uses: ruby/setup-ruby@v1
18
18
  with:
19
- ruby-version: '3.3'
19
+ ruby-version: '3.4.8'
20
20
  - name: cache gems
21
- uses: actions/cache@v4
21
+ uses: actions/cache@v5
22
22
  with:
23
23
  path: vendor/bundle
24
24
  key: ${{ runner.os }}-rubocop-${{ hashFiles('**/Gemfile.lock') }}
@@ -23,7 +23,7 @@ jobs:
23
23
  env:
24
24
  AR_VERSION: ${{ matrix.ar }}
25
25
  steps:
26
- - uses: actions/checkout@v4
26
+ - uses: actions/checkout@v6
27
27
  - name: Set up Ruby
28
28
  uses: ruby/setup-ruby@v1
29
29
  with:
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "2.3.0"
2
+ ".": "2.4.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ### 2.4.0 (2026-02-04)
4
+
5
+ #### Features
6
+
7
+ * Add automatic PDML fallback for mutation limit errors ([#369](https://github.com/googleapis/ruby-spanner-activerecord/issues/369))
8
+ * Adding support for exclude_txn_from_change_streams option ([#368](https://github.com/googleapis/ruby-spanner-activerecord/issues/368)) ([1b04c70](https://github.com/googleapis/ruby-spanner-activerecord/commit/1b04c70b4ecec62403449af11827d0f3587acfd0)), closes [#367](https://github.com/googleapis/ruby-spanner-activerecord/issues/367)
9
+ * Batch DML support ([#370](https://github.com/googleapis/ruby-spanner-activerecord/issues/370))
10
+ * support commit_options ([#364](https://github.com/googleapis/ruby-spanner-activerecord/issues/364)) ([0a1020a](https://github.com/googleapis/ruby-spanner-activerecord/commit/0a1020ac60ff9ddaae1634a2178a71bc0de9480c)), closes [#365](https://github.com/googleapis/ruby-spanner-activerecord/issues/365)
11
+
3
12
  ### 2.3.0 (2025-05-30)
4
13
 
5
14
  #### Features
data/Gemfile CHANGED
@@ -6,10 +6,11 @@ gemspec
6
6
  ar_version = ENV.fetch("AR_VERSION", "~> 7.1.0")
7
7
  gem "activerecord", ar_version
8
8
  gem "ostruct"
9
- gem "minitest", "~> 5.25.0"
9
+ gem "minitest", "~> 5.27.0"
10
10
  gem "minitest-rg", "~> 5.3.0"
11
11
  gem "pry", "~> 0.14.2"
12
12
  gem "pry-byebug", "~> 3.11.0"
13
+ gem "mutex_m"
13
14
  # Add sqlite3 for testing for compatibility with other adapters.
14
15
  gem 'sqlite3'
15
16
 
@@ -86,6 +86,8 @@ module ActiveRecord
86
86
  filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(config_name, :sql)
87
87
  elsif ActiveRecord::Tasks::DatabaseTasks.respond_to?(:schema_dump_path)
88
88
  filename = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config, :sql)
89
+ else
90
+ raise "No schema file specified"
89
91
  end
90
92
  ActiveRecord::Tasks::DatabaseTasks.dump_schema db_config, :sql
91
93
  sql = File.read(filename)
@@ -98,10 +100,21 @@ module ActiveRecord
98
100
  else
99
101
  assert_equal expected_schema_sql_on_production, sql, msg = sql
100
102
  end
101
- drop_database
102
- create_database
103
- ActiveRecord::Tasks::DatabaseTasks.load_schema db_config, :sql
104
- assert_equal tables, connection.tables.sort
103
+
104
+ # Skip the drop-and-recreate test on production, for two reasons:
105
+ # 1. It is relatively slow.
106
+ # 2. Dropping and re-creating a database with the same name while keeping the connection open
107
+ # causes 'Database not found' errors to be returned (by the session that is used?)
108
+ if ENV["SPANNER_EMULATOR_HOST"]
109
+ drop_database
110
+ create_database
111
+ begin
112
+ ActiveRecord::Tasks::DatabaseTasks.load_schema db_config, :sql, file = filename
113
+ rescue StandardError => e
114
+ puts "Loading schema failed: #{e}"
115
+ end
116
+ assert_equal tables, connection.tables.sort
117
+ end
105
118
  end
106
119
 
107
120
  def expected_schema_sql_on_emulator
@@ -273,6 +273,35 @@ module ActiveRecord
273
273
  TableWithSequence.connection.use_client_side_id_for_mutations = reset_value
274
274
  end
275
275
  end
276
+
277
+ def test_single_dml_succeeds_with_fallback_to_pdml_enabled
278
+ # This test verifies that a normal, successful DML statement works as
279
+ # expected when the fallback isolation is enabled. Because no mutation
280
+ # limit error occurs, the fallback to PDML should NOT be triggered.
281
+ create_test_records
282
+ assert_equal 1, Author.count
283
+
284
+ Author.transaction isolation: :fallback_to_pdml do
285
+ Author.where(name: "David").delete_all
286
+ end
287
+
288
+ assert_equal 0, Author.count, "The record should have been deleted"
289
+ end
290
+
291
+ def test_other_errors_do_not_trigger_fallback
292
+ # This test ensures that if a transaction with fallback enabled fails
293
+ # for a reason OTHER than the mutation limit, it fails normally and
294
+ # does not attempt to fall back to PDML.
295
+ create_test_records
296
+ initial_author_count = Author.count
297
+
298
+ assert_raises ActiveRecord::StatementInvalid do
299
+ Author.transaction isolation: :fallback_to_pdml do
300
+ Author.create! name: nil
301
+ end
302
+ end
303
+ assert_equal initial_author_count, Author.count, "Transaction should have rolled back"
304
+ end
276
305
  end
277
306
  end
278
307
  end
@@ -0,0 +1,13 @@
1
+ # Sample - Batch DML
2
+
3
+ This example shows how to use [Batch DML](https://cloud.google.com/spanner/docs/dml-tasks#use-batch)
4
+ with the Spanner ActiveRecord adapter.
5
+
6
+ The sample will automatically start a Spanner Emulator in a docker container and execute the sample
7
+ against that emulator. The emulator will automatically be stopped when the application finishes.
8
+
9
+ Run the application with the command
10
+
11
+ ```bash
12
+ bundle exec rake run
13
+ ```
@@ -0,0 +1,13 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ require_relative "../config/environment"
8
+ require "sinatra/activerecord/rake"
9
+
10
+ desc "Sample showing how to use Batch DML with the Spanner ActiveRecord provider."
11
+ task :run do
12
+ Dir.chdir("..") { sh "bundle exec rake run[batch-dml]" }
13
+ end
@@ -0,0 +1,54 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ require "io/console"
8
+ require_relative "../config/environment"
9
+ require_relative "models/singer"
10
+ require_relative "models/album"
11
+
12
+ class Application
13
+ def self.run
14
+ first_names = %w[Pete Alice John Ethel Trudy Naomi Wendy Ruben Thomas Elly]
15
+ last_names = %w[Wendelson Allison Peterson Johnson Henderson Ericsson Aronson Tennet Courtou]
16
+
17
+ # Insert 5 new singers using Batch DML.
18
+ ActiveRecord::Base.transaction do
19
+ # The Base.dml_batch function starts a DML batch. All DML statements that are
20
+ # generated by ActiveRecord inside the block that is given will be added to
21
+ # the current batch. The batch is executed at the end of the block. The data
22
+ # that has been written is readable after the block ends.
23
+ ActiveRecord::Base.dml_batch do
24
+ 5.times do
25
+ Singer.create first_name: first_names.sample, last_name: last_names.sample
26
+ end
27
+ end
28
+ # Data that has been inserted/update using Batch DML can be read in the same
29
+ # transaction as the one that added/updated the data. This is different from
30
+ # mutations, as mutations do not support read-your-writes.
31
+ singers = Singer.all
32
+ puts "Inserted #{singers.count} singers in one batch"
33
+ end
34
+
35
+ # Batch DML can also be used to update existing data.
36
+ ActiveRecord::Base.transaction do
37
+ # Start a DML batch.
38
+ singers = nil
39
+ ActiveRecord::Base.dml_batch do
40
+ # Queries can be executed inside a DML batch.
41
+ # These are executed directly and do not affect the DML batch.
42
+ singers = Singer.all
43
+ singers.each do |singer|
44
+ singer.picture = Base64.encode64 SecureRandom.alphanumeric(SecureRandom.random_number(10..200))
45
+ singer.save
46
+ end
47
+ end
48
+ puts "Updated #{singers.count} singers in one batch"
49
+ end
50
+ end
51
+ end
52
+
53
+
54
+ Application.run
@@ -0,0 +1,11 @@
1
+ # Batch DML Example Configuration File
2
+ # This file is used to configure the database connection for the batch DML example.
3
+ development:
4
+ adapter: spanner
5
+ emulator_host: localhost:9010
6
+ project: test-project
7
+ instance: test-instance
8
+ database: testdb
9
+ pool: 5
10
+ timeout: 5000
11
+ schema_dump: false
@@ -0,0 +1,23 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ class CreateTables < ActiveRecord::Migration[6.0]
8
+ def change
9
+ connection.ddl_batch do
10
+ create_table :singers do |t|
11
+ t.string :first_name
12
+ t.string :last_name
13
+ t.binary :picture
14
+ end
15
+
16
+ create_table :albums do |t|
17
+ t.string :title
18
+ t.numeric :marketing_budget
19
+ t.references :singer, index: false, foreign_key: true
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,8 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ # This file is intentionally kept empty, as this sample
8
+ # does not need any initial data.
@@ -0,0 +1,9 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ class Album < ActiveRecord::Base
8
+ belongs_to :singer
9
+ end
@@ -0,0 +1,9 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ class Singer < ActiveRecord::Base
8
+ has_many :albums
9
+ end
@@ -26,6 +26,37 @@ class Application
26
26
  puts "Deleted #{count} albums"
27
27
  end
28
28
 
29
+ puts ""
30
+ puts "Deleting all singers in the database using normal Read-Write transaction with PDML fallback"
31
+ #
32
+ # This example demonstrates using `isolation: :fallback_to_pdml`.
33
+ #
34
+ # --- HOW IT WORKS ---
35
+ # 1. Initial Attempt: The transaction starts as a normal, atomic, read-write transaction.
36
+ #
37
+ # 2. The Trigger: If that transaction fails with a `TransactionMutationLimitExceededError`,
38
+ # the adapter automatically catches the error.
39
+ #
40
+ # 3. The Fallback: The adapter then retries the ENTIRE code block in a new,
41
+ # non-atomic Partitioned DML (PDML) transaction.
42
+ #
43
+ # --- USAGE REQUIREMENTS ---
44
+ # This implementation retries the whole transaction block without checking its contents.
45
+ # The user of this feature is responsible for ensuring the following:
46
+ #
47
+ # 1. SINGLE DML STATEMENT: The block should contain only ONE DML statement.
48
+ # If it contains more, the PDML retry will fail with a low-level `seqno` error.
49
+ #
50
+ # 2. IDEMPOTENCY: The DML statement must be idempotent. See https://cloud.google.com/spanner/docs/dml-partitioned#partitionable-idempotent for more information. # rubocop:disable Layout/LineLength
51
+ #
52
+ # 3. NON-ATOMIC: The retried PDML transaction is NOT atomic. Do not use this
53
+ # for multi-step operations that must all succeed or fail together.
54
+ #
55
+ Singer.transaction isolation: :fallback_to_pdml do
56
+ count = Singer.delete_all
57
+ puts "Deleted #{count} singers"
58
+ end
59
+
29
60
  puts ""
30
61
  puts "Deleting all singers in the database using Partitioned DML"
31
62
  Singer.transaction isolation: :pdml do
@@ -7,6 +7,7 @@
7
7
  # frozen_string_literal: true
8
8
 
9
9
  require "active_record/gem_version"
10
+ require "active_record/connection_adapters/spanner/errors/transaction_mutation_limit_exceeded_error"
10
11
 
11
12
  module ActiveRecord
12
13
  module ConnectionAdapters
@@ -14,6 +15,7 @@ module ActiveRecord
14
15
  module DatabaseStatements
15
16
  VERSION_7_1_0 = Gem::Version.create "7.1.0"
16
17
  RequestOptions = Google::Cloud::Spanner::V1::RequestOptions
18
+ TransactionMutationLimitExceededError = Google::Cloud::Spanner::Errors::TransactionMutationLimitExceededError
17
19
 
18
20
  # DDL, DML and DQL Statements
19
21
 
@@ -23,9 +25,13 @@ module ActiveRecord
23
25
 
24
26
  def internal_exec_query sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false
25
27
  result = internal_execute sql, name, binds, prepare: prepare, async: async, allow_retry: allow_retry
26
- ActiveRecord::Result.new(
27
- result.fields.keys.map(&:to_s), result.rows.map(&:values)
28
- )
28
+ if result
29
+ ActiveRecord::Result.new(
30
+ result.fields.keys.map(&:to_s), result.rows.map(&:values)
31
+ )
32
+ else
33
+ ActiveRecord::Result.new [], []
34
+ end
29
35
  end
30
36
 
31
37
  def internal_execute sql, name = "SQL", binds = [],
@@ -72,11 +78,19 @@ module ActiveRecord
72
78
  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
73
79
  if transaction_required
74
80
  transaction do
75
- @connection.execute_query sql, params: params, types: types, request_options: request_options
81
+ @connection.execute_query sql,
82
+ params: params,
83
+ types: types,
84
+ request_options: request_options,
85
+ statement_type: statement_type
76
86
  end
77
87
  else
78
- @connection.execute_query sql, params: params, types: types, single_use_selector: selector,
79
- request_options: request_options
88
+ @connection.execute_query sql,
89
+ params: params,
90
+ types: types,
91
+ single_use_selector: selector,
92
+ request_options: request_options,
93
+ statement_type: statement_type
80
94
  end
81
95
  end
82
96
  end
@@ -142,9 +156,13 @@ module ActiveRecord
142
156
 
143
157
  def exec_query sql, name = "SQL", binds = [], prepare: false # rubocop:disable Lint/UnusedMethodArgument
144
158
  result = execute sql, name, binds
145
- ActiveRecord::Result.new(
146
- result.fields.keys.map(&:to_s), result.rows.map(&:values)
147
- )
159
+ if result.respond_to? :fields
160
+ ActiveRecord::Result.new(
161
+ result.fields.keys.map(&:to_s), result.rows.map(&:values)
162
+ )
163
+ else
164
+ ActiveRecord::Result.new [], []
165
+ end
148
166
  end
149
167
 
150
168
  def sql_for_insert sql, pk, binds
@@ -190,6 +208,12 @@ module ActiveRecord
190
208
  alias delete update
191
209
 
192
210
  def exec_update sql, name = "SQL", binds = []
211
+ # Check if a DML batch is active on the connection.
212
+ if @connection.dml_batch?
213
+ # This call buffers the SQL.
214
+ execute sql, name, binds
215
+ return
216
+ end
193
217
  result = execute sql, name, binds
194
218
  # Make sure that we consume the entire result stream before trying to get the stats.
195
219
  # This is required because the ExecuteStreamingSql RPC is also used for (Partitioned) DML,
@@ -229,20 +253,45 @@ module ActiveRecord
229
253
 
230
254
  # Transaction
231
255
 
232
- def transaction requires_new: nil, isolation: nil, joinable: true
256
+ def transaction requires_new: nil, isolation: nil, joinable: true, **kwargs, &block # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
257
+ commit_options = kwargs.delete :commit_options
258
+ exclude_from_streams = kwargs.delete :exclude_txn_from_change_streams
259
+ @_spanner_begin_transaction_options = {
260
+ exclude_txn_from_change_streams: exclude_from_streams
261
+ }
233
262
  if !requires_new && current_transaction.joinable?
234
263
  return super
235
264
  end
236
265
 
237
266
  backoff = 0.2
238
267
  begin
239
- super
268
+ super do
269
+ # Once the transaction has been started by `super`, apply your custom options
270
+ # to the Spanner transaction object.
271
+ if commit_options && @connection.current_transaction
272
+ @connection.current_transaction.set_commit_options commit_options
273
+ end
274
+
275
+ yield
276
+ end
240
277
  rescue ActiveRecord::StatementInvalid => err
241
278
  if err.cause.is_a? Google::Cloud::AbortedError
242
- sleep(delay_from_aborted(err) || backoff *= 1.3)
279
+ sleep(delay_from_aborted(err) || (backoff *= 1.3))
280
+ retry
281
+ elsif TransactionMutationLimitExceededError.is_mutation_limit_error? err.cause
282
+ is_fallback_enabled = isolation == :fallback_to_pdml
283
+ raise unless is_fallback_enabled
284
+ @_spanner_begin_transaction_options[:isolation] = :pdml
243
285
  retry
286
+ else
287
+ raise
244
288
  end
245
- raise
289
+ rescue Google::Cloud::AbortedError => err
290
+ sleep(delay_from_aborted(err) || backoff *= 1.3)
291
+ retry
292
+ ensure
293
+ # Clean up the instance variable to avoid leaking options.
294
+ @_spanner_begin_transaction_options = nil
246
295
  end
247
296
  end
248
297
 
@@ -256,13 +305,15 @@ module ActiveRecord
256
305
  # These are not really isolation levels, but it is the only (best) way to pass in additional
257
306
  # transaction options to the connection.
258
307
  read_only: "READ_ONLY",
259
- buffered_mutations: "BUFFERED_MUTATIONS"
308
+ buffered_mutations: "BUFFERED_MUTATIONS",
309
+ fallback_to_pdml: "FALLBACK_TO_PDML"
260
310
  }
261
311
  end
262
312
 
263
313
  def begin_db_transaction
264
314
  log "BEGIN" do
265
- @connection.begin_transaction
315
+ opts = @_spanner_begin_transaction_options || {}
316
+ @connection.begin_transaction nil, **opts
266
317
  end
267
318
  end
268
319
 
@@ -285,18 +336,22 @@ module ActiveRecord
285
336
  # (this is the same as :read_only)
286
337
  #
287
338
  def begin_isolated_db_transaction isolation
288
- if isolation.is_a? Hash
289
- raise "Unsupported isolation level: #{isolation}" unless
290
- isolation[:timestamp] || isolation[:staleness] || isolation[:strong]
339
+ opts = @_spanner_begin_transaction_options || {}
340
+ # If isolation level is specified in the options, use that instead of the default isolation level.
341
+ isolation_option = opts[:isolation] || isolation
342
+ if isolation_option.is_a? Hash
343
+ raise "Unsupported isolation level: #{isolation_option}" unless
344
+ isolation_option[:timestamp] || isolation_option[:staleness] || isolation_option[:strong]
291
345
  raise "Only one option is supported. It must be one of `timestamp`, `staleness` or `strong`." \
292
- if isolation.count != 1
346
+ if isolation_option.count != 1
293
347
  else
294
- raise "Unsupported isolation level: #{isolation}" unless
295
- [:serializable, :repeatable_read, :read_only, :buffered_mutations, :pdml].include? isolation
348
+ raise "Unsupported isolation level: #{isolation_option}" unless
349
+ [:serializable, :repeatable_read, :read_only, :buffered_mutations, :pdml,
350
+ :fallback_to_pdml].include? isolation_option
296
351
  end
297
352
 
298
- log "BEGIN #{isolation}" do
299
- @connection.begin_transaction isolation
353
+ log "BEGIN #{isolation_option}" do
354
+ @connection.begin_transaction isolation_option, **opts.except(:isolation)
300
355
  end
301
356
  end
302
357
 
@@ -402,7 +457,7 @@ module ActiveRecord
402
457
 
403
458
  private_class_method def self.build_sql_statement_regexp *parts # :nodoc:
404
459
  parts = parts.map { |part| /#{part}/i }
405
- /\A(?:[\(\s]|#{COMMENT_REGEX})*#{Regexp.union(*parts)}/
460
+ /\A(?:[(\s]|#{COMMENT_REGEX})*#{Regexp.union(*parts)}/
406
461
  end
407
462
 
408
463
  DDL_REGX = build_sql_statement_regexp(:create, :alter, :drop).freeze
@@ -0,0 +1,25 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ module Google
8
+ module Cloud
9
+ module Spanner
10
+ module Errors
11
+ # Custom exception raised when a transaction exceeds the mutation limit in Google Cloud Spanner.
12
+ # This provides a specific error class for a common, recoverable scenario.
13
+ class TransactionMutationLimitExceededError < ActiveRecord::StatementInvalid
14
+ ERROR_MESSAGE = "The transaction contains too many mutations".freeze
15
+
16
+ def self.is_mutation_limit_error? error
17
+ return false if error.nil?
18
+ error.is_a?(Google::Cloud::InvalidArgumentError) &&
19
+ error.message&.include?(ERROR_MESSAGE)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -150,7 +150,8 @@ module ActiveRecord
150
150
  end
151
151
 
152
152
  # Spanner Connection API
153
- delegate :ddl_batch, :ddl_batch?, :start_batch_ddl, :abort_batch, :run_batch,
153
+ delegate :dml_batch, :dml_batch?, :start_batch_dml,
154
+ :ddl_batch, :ddl_batch?, :start_batch_ddl, :abort_batch, :run_batch,
154
155
  :isolation_level, :isolation_level=, to: :@connection
155
156
 
156
157
  def current_spanner_transaction
@@ -49,6 +49,10 @@ module ActiveRecord
49
49
  spanner_adapter? && connection&.current_spanner_transaction&.isolation == :buffered_mutations
50
50
  end
51
51
 
52
+ def self.dml_batch(&)
53
+ connection.dml_batch(&)
54
+ end
55
+
52
56
  def self._should_use_standard_insert_record? values
53
57
  !(buffered_mutations? || (primary_key && values.is_a?(Hash))) || !spanner_adapter?
54
58
  end
@@ -7,8 +7,11 @@
7
7
  require "google/cloud/spanner"
8
8
  require "spanner_client_ext"
9
9
  require "activerecord_spanner_adapter/information_schema"
10
+ require_relative "../active_record/connection_adapters/spanner/errors/transaction_mutation_limit_exceeded_error"
10
11
 
11
12
  module ActiveRecordSpannerAdapter
13
+ TransactionMutationLimitExceededError = Google::Cloud::Spanner::Errors::TransactionMutationLimitExceededError
14
+
12
15
  class Connection
13
16
  attr_reader :instance_id
14
17
  attr_reader :database_id
@@ -44,6 +47,8 @@ module ActiveRecordSpannerAdapter
44
47
  # Call this method if you drop and recreate a database with the same name
45
48
  # to prevent the cached information to be used for the new database.
46
49
  def self.reset_information_schemas!
50
+ return unless @database
51
+
47
52
  @information_schemas.each_value do |info_schema|
48
53
  info_schema.connection.disconnect!
49
54
  end
@@ -159,6 +164,12 @@ module ActiveRecordSpannerAdapter
159
164
  false
160
165
  end
161
166
 
167
+ # Returns true if this connection is currently executing a DML batch, and otherwise false.
168
+ def dml_batch?
169
+ return true if @dml_batch
170
+ false
171
+ end
172
+
162
173
  ##
163
174
  # Starts a manual DDL batch. The batch must be ended by calling either run_batch or abort_batch.
164
175
  #
@@ -173,18 +184,39 @@ module ActiveRecordSpannerAdapter
173
184
  # raise
174
185
  # end
175
186
  def start_batch_ddl
176
- if @ddl_batch
177
- raise Google::Cloud::FailedPreconditionError, "A DDL batch is already active on this connection"
187
+ if @ddl_batch && @dml_batch
188
+ raise Google::Cloud::FailedPreconditionError, "Batch is already active on this connection"
178
189
  end
179
190
  @ddl_batch = []
180
191
  end
181
192
 
193
+ ##
194
+ # Starts a manual DML batch. The batch must be ended by calling either run_batch or abort_batch.
195
+ #
196
+ # @example
197
+ # begin
198
+ # connection.start_batch_dml
199
+ # connection.execute_query "insert into `Users` (Id, Name) VALUES (1, 'Test 1')"
200
+ # connection.execute_query "insert into `Users` (Id, Name) VALUES (2, 'Test 2')"
201
+ # connection.run_batch
202
+ # rescue StandardError
203
+ # connection.abort_batch
204
+ # raise
205
+ # end
206
+ def start_batch_dml
207
+ if @ddl_batch || @dml_batch
208
+ raise Google::Cloud::FailedPreconditionError, "A batch is already active on this connection"
209
+ end
210
+ @dml_batch = []
211
+ end
212
+
182
213
  ##
183
214
  # Aborts the current batch on this connection. This is a no-op if there is no batch on this connection.
184
215
  #
185
216
  # @see start_batch_ddl
186
217
  def abort_batch
187
218
  @ddl_batch = nil
219
+ @dml_batch = nil
188
220
  end
189
221
 
190
222
  ##
@@ -193,10 +225,21 @@ module ActiveRecordSpannerAdapter
193
225
  #
194
226
  # @see start_batch_ddl
195
227
  def run_batch
196
- unless @ddl_batch
228
+ unless @ddl_batch || @dml_batch
197
229
  raise Google::Cloud::FailedPreconditionError, "There is no batch active on this connection"
198
230
  end
199
231
  # Just return if the batch is empty.
232
+ return true if @ddl_batch&.empty? || @dml_batch&.empty?
233
+ begin
234
+ if @ddl_batch
235
+ run_ddl_batch
236
+ else
237
+ run_dml_batch
238
+ end
239
+ end
240
+ end
241
+
242
+ def run_ddl_batch
200
243
  return true if @ddl_batch.empty?
201
244
  begin
202
245
  execute_ddl_statements @ddl_batch, nil, true
@@ -205,9 +248,43 @@ module ActiveRecordSpannerAdapter
205
248
  end
206
249
  end
207
250
 
251
+ def run_dml_batch
252
+ return true if @dml_batch.empty?
253
+ begin
254
+ # Execute the DML statements in the batch.
255
+ execute_dml_statements_in_batch @dml_batch
256
+ ensure
257
+ @dml_batch = nil
258
+ end
259
+ end
260
+
261
+ ##
262
+ # Executes a set of DML statements as one batch. This method raises an error if no block is given.
263
+ def dml_batch
264
+ raise Google::Cloud::FailedPreconditionError, "No block given for the DML batch" unless block_given?
265
+ begin
266
+ start_batch_dml
267
+ yield
268
+ run_batch
269
+ rescue StandardError
270
+ abort_batch
271
+ raise
272
+ ensure
273
+ @dml_batch = nil
274
+ end
275
+ end
276
+
208
277
  # DQL, DML Statements
209
278
 
210
- def execute_query sql, params: nil, types: nil, single_use_selector: nil, request_options: nil
279
+ def execute_query sql, params: nil, types: nil, single_use_selector: nil, request_options: nil, statement_type: nil
280
+ # Clear the transaction from the previous statement.
281
+ unless current_transaction&.active?
282
+ self.current_transaction = nil
283
+ end
284
+ if statement_type == :dml && dml_batch?
285
+ @dml_batch.push({ sql: sql, params: params, types: types })
286
+ return
287
+ end
211
288
  if params
212
289
  converted_params, types =
213
290
  Google::Cloud::Spanner::Convert.to_input_params_and_types(
@@ -215,11 +292,6 @@ module ActiveRecordSpannerAdapter
215
292
  )
216
293
  end
217
294
 
218
- # Clear the transaction from the previous statement.
219
- unless current_transaction&.active?
220
- self.current_transaction = nil
221
- end
222
-
223
295
  selector = transaction_selector || single_use_selector
224
296
  execute_sql_request sql, converted_params, types, selector, request_options
225
297
  end
@@ -250,6 +322,9 @@ module ActiveRecordSpannerAdapter
250
322
  end
251
323
  raise
252
324
  rescue Google::Cloud::Error => e
325
+ if TransactionMutationLimitExceededError.is_mutation_limit_error? e
326
+ raise
327
+ end
253
328
  # Check if it was the first statement in a transaction that included a BeginTransaction
254
329
  # option in the request. If so, execute an explicit BeginTransaction and then retry the
255
330
  # request without the BeginTransaction option.
@@ -274,9 +349,11 @@ module ActiveRecordSpannerAdapter
274
349
 
275
350
  # Transactions
276
351
 
277
- def begin_transaction isolation = nil
352
+ def begin_transaction isolation = nil, **options
278
353
  raise "Nested transactions are not allowed" if current_transaction&.active?
279
- self.current_transaction = Transaction.new self, isolation || @isolation_level
354
+ exclude_from_streams = options.fetch :exclude_txn_from_change_streams, false
355
+ self.current_transaction = Transaction.new self, isolation || @isolation_level,
356
+ exclude_txn_from_change_streams: exclude_from_streams
280
357
  current_transaction.begin
281
358
  current_transaction
282
359
  end
@@ -343,6 +420,42 @@ module ActiveRecordSpannerAdapter
343
420
  job.done?
344
421
  end
345
422
 
423
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
424
+ def execute_dml_statements_in_batch statements
425
+ selector = transaction_selector
426
+ response = session.batch_update selector, current_transaction&.next_sequence_number do |batch|
427
+ statements.each do |statement|
428
+ batch.batch_update statement[:sql], params: statement[:params], types: statement[:types]
429
+ end
430
+ end
431
+ batch_update_results = Google::Cloud::Spanner::BatchUpdateResults.new response
432
+ first_res = response.result_sets.first
433
+ current_transaction.grpc_transaction = first_res.metadata.transaction \
434
+ if current_transaction && first_res&.metadata&.transaction
435
+ batch_update_results.row_counts
436
+ rescue Google::Cloud::AbortedError
437
+ # Mark the current transaction as aborted to prevent any unnecessary further requests on the transaction.
438
+ current_transaction&.mark_aborted
439
+ raise
440
+ rescue Google::Cloud::Spanner::BatchUpdateError => e
441
+ # Check if the status is ABORTED, and if it is, just propagate an AbortedError.
442
+ if e.cause&.code == GRPC::Core::StatusCodes::ABORTED
443
+ current_transaction&.mark_aborted
444
+ raise e.cause
445
+ end
446
+
447
+ # Check if the request returned a transaction or not.
448
+ # BatchDML is capable of returning BOTH an error and a transaction.
449
+ if current_transaction && !current_transaction.grpc_transaction? && selector&.begin&.read_write
450
+ selector = create_transaction_after_failed_first_statement e
451
+ retry
452
+ end
453
+ # It was not the first statement, or it returned a transaction, so propagate the error.
454
+ raise
455
+ end
456
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
457
+
458
+
346
459
  ##
347
460
  # Retrieves the delay value from Google::Cloud::AbortedError or
348
461
  # GRPC::Aborted
@@ -7,14 +7,21 @@
7
7
  module ActiveRecordSpannerAdapter
8
8
  class Transaction
9
9
  attr_reader :state
10
+ attr_reader :commit_options
11
+ attr_reader :begin_transaction_selector
12
+ attr_accessor :exclude_txn_from_change_streams
10
13
 
11
- def initialize connection, isolation
14
+
15
+
16
+ def initialize connection, isolation, commit_options = nil, exclude_txn_from_change_streams: false
12
17
  @connection = connection
13
18
  @isolation = isolation
14
19
  @committable = ![:read_only, :pdml].include?(isolation) && !isolation.is_a?(Hash)
15
20
  @state = :INITIALIZED
16
21
  @sequence_number = 0
17
22
  @mutations = []
23
+ @commit_options = commit_options
24
+ @exclude_txn_from_change_streams = exclude_txn_from_change_streams
18
25
  end
19
26
 
20
27
  def active?
@@ -59,7 +66,8 @@ module ActiveRecordSpannerAdapter
59
66
  @begin_transaction_selector = Google::Cloud::Spanner::V1::TransactionSelector.new \
60
67
  begin: Google::Cloud::Spanner::V1::TransactionOptions.new(
61
68
  read_write: Google::Cloud::Spanner::V1::TransactionOptions::ReadWrite.new,
62
- isolation_level: grpc_isolation
69
+ isolation_level: grpc_isolation,
70
+ exclude_txn_from_change_streams: @exclude_txn_from_change_streams
63
71
  )
64
72
  end
65
73
  @state = :STARTED
@@ -95,14 +103,23 @@ module ActiveRecordSpannerAdapter
95
103
  @sequence_number += 1 if @committable
96
104
  end
97
105
 
106
+ # Sets the commit options for this transaction.
107
+ # This is used to set the options for the commit RPC, such as return_commit_stats and max_commit_delay.
108
+ def set_commit_options options # rubocop:disable Naming/AccessorMethodName
109
+ @commit_options = options&.dup
110
+ end
111
+
98
112
  def commit
99
113
  raise "This transaction is not active" unless active?
100
114
 
101
115
  begin
102
116
  # Start a transaction with an explicit BeginTransaction RPC if the transaction only contains mutations.
103
117
  force_begin_read_write if @committable && !@mutations.empty? && !@grpc_transaction
104
-
105
- @connection.session.commit_transaction @grpc_transaction, @mutations if @committable && @grpc_transaction
118
+ if @committable && @grpc_transaction
119
+ @connection.session.commit_transaction @grpc_transaction,
120
+ @mutations,
121
+ commit_options: commit_options
122
+ end
106
123
  @state = :COMMITTED
107
124
  rescue Google::Cloud::NotFoundError => e
108
125
  if @connection.session_not_found? e
@@ -148,6 +165,10 @@ module ActiveRecordSpannerAdapter
148
165
  @grpc_transaction = Google::Cloud::Spanner::Transaction.from_grpc grpc, @connection.session
149
166
  end
150
167
 
168
+ def grpc_transaction?
169
+ @grpc_transaction if @grpc_transaction
170
+ end
171
+
151
172
  def transaction_selector
152
173
  return unless active?
153
174
 
@@ -5,5 +5,5 @@
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
7
  module ActiveRecordSpannerAdapter
8
- VERSION = "2.3.0".freeze
8
+ VERSION = "2.4.0".freeze
9
9
  end
@@ -23,13 +23,14 @@ module Google
23
23
  end
24
24
 
25
25
  class Session
26
- def commit_transaction transaction, mutations = []
26
+ def commit_transaction transaction, mutations = [], commit_options: nil
27
27
  ensure_service!
28
28
 
29
29
  resp = service.commit(
30
30
  path,
31
31
  mutations,
32
- transaction_id: transaction.transaction_id
32
+ transaction_id: transaction.transaction_id,
33
+ commit_options: commit_options
33
34
  )
34
35
  @last_updated_at = Time.now
35
36
  Convert.timestamp_to_time resp.commit_timestamp
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-spanner-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Google LLC
@@ -365,6 +365,14 @@ files:
365
365
  - examples/snippets/auto-generated-primary-key/db/seeds.rb
366
366
  - examples/snippets/auto-generated-primary-key/models/album.rb
367
367
  - examples/snippets/auto-generated-primary-key/models/singer.rb
368
+ - examples/snippets/batch-dml/README.md
369
+ - examples/snippets/batch-dml/Rakefile
370
+ - examples/snippets/batch-dml/application.rb
371
+ - examples/snippets/batch-dml/config/database.yml
372
+ - examples/snippets/batch-dml/db/migrate/01_create_tables.rb
373
+ - examples/snippets/batch-dml/db/seeds.rb
374
+ - examples/snippets/batch-dml/models/album.rb
375
+ - examples/snippets/batch-dml/models/singer.rb
368
376
  - examples/snippets/bin/create_emulator_instance.rb
369
377
  - examples/snippets/bit-reversed-sequence/README.md
370
378
  - examples/snippets/bit-reversed-sequence/Rakefile
@@ -538,6 +546,7 @@ files:
538
546
  - examples/solidus/README.md
539
547
  - lib/active_record/connection_adapters/spanner/column.rb
540
548
  - lib/active_record/connection_adapters/spanner/database_statements.rb
549
+ - lib/active_record/connection_adapters/spanner/errors/transaction_mutation_limit_exceeded_error.rb
541
550
  - lib/active_record/connection_adapters/spanner/quoting.rb
542
551
  - lib/active_record/connection_adapters/spanner/schema_cache.rb
543
552
  - lib/active_record/connection_adapters/spanner/schema_creation.rb