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.
- checksums.yaml +4 -4
- data/.github/workflows/acceptance-tests-on-emulator.yaml +1 -1
- data/.github/workflows/acceptance-tests-on-production.yaml +4 -4
- data/.github/workflows/ci.yaml +1 -1
- data/.github/workflows/nightly-acceptance-tests-on-emulator.yaml +1 -1
- data/.github/workflows/nightly-acceptance-tests-on-production.yaml +3 -3
- data/.github/workflows/nightly-unit-tests.yaml +1 -1
- data/.github/workflows/rubocop.yaml +3 -3
- data/.github/workflows/samples.yaml +1 -1
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +9 -0
- data/Gemfile +2 -1
- data/acceptance/cases/tasks/database_tasks_test.rb +17 -4
- data/acceptance/cases/transactions/read_write_transactions_test.rb +29 -0
- data/examples/snippets/batch-dml/README.md +13 -0
- data/examples/snippets/batch-dml/Rakefile +13 -0
- data/examples/snippets/batch-dml/application.rb +54 -0
- data/examples/snippets/batch-dml/config/database.yml +11 -0
- data/examples/snippets/batch-dml/db/migrate/01_create_tables.rb +23 -0
- data/examples/snippets/batch-dml/db/seeds.rb +8 -0
- data/examples/snippets/batch-dml/models/album.rb +9 -0
- data/examples/snippets/batch-dml/models/singer.rb +9 -0
- data/examples/snippets/partitioned-dml/application.rb +31 -0
- data/lib/active_record/connection_adapters/spanner/database_statements.rb +79 -24
- data/lib/active_record/connection_adapters/spanner/errors/transaction_mutation_limit_exceeded_error.rb +25 -0
- data/lib/active_record/connection_adapters/spanner_adapter.rb +2 -1
- data/lib/activerecord_spanner_adapter/base.rb +4 -0
- data/lib/activerecord_spanner_adapter/connection.rb +124 -11
- data/lib/activerecord_spanner_adapter/transaction.rb +25 -4
- data/lib/activerecord_spanner_adapter/version.rb +1 -1
- data/lib/spanner_client_ext.rb +3 -2
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb3f5f2106d42a64cf32616e119f188cc13a1b81acfa040765ae4b5cb2838cf1
|
|
4
|
+
data.tar.gz: 5ff65bbec67470ddc479f6ba985e9d31c0562848b9f0739e697ceb1d6715e3dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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
|
data/.github/workflows/ci.yaml
CHANGED
|
@@ -25,7 +25,7 @@ jobs:
|
|
|
25
25
|
env:
|
|
26
26
|
AR_VERSION: ${{ matrix.ar }}
|
|
27
27
|
steps:
|
|
28
|
-
- uses: actions/checkout@
|
|
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):
|
|
@@ -12,7 +12,7 @@ jobs:
|
|
|
12
12
|
matrix:
|
|
13
13
|
ruby: [3.3]
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
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@
|
|
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@
|
|
28
|
+
uses: google-github-actions/setup-gcloud@v3
|
|
29
29
|
with:
|
|
30
30
|
project_id: ${{ secrets.GCP_PROJECT_ID }}
|
|
31
31
|
- name: Install dependencies
|
|
@@ -12,13 +12,13 @@ jobs:
|
|
|
12
12
|
timeout-minutes: 10
|
|
13
13
|
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
15
|
+
- uses: actions/checkout@v6
|
|
16
16
|
- name: setup ruby
|
|
17
17
|
uses: ruby/setup-ruby@v1
|
|
18
18
|
with:
|
|
19
|
-
ruby-version: '3.
|
|
19
|
+
ruby-version: '3.4.8'
|
|
20
20
|
- name: cache gems
|
|
21
|
-
uses: actions/cache@
|
|
21
|
+
uses: actions/cache@v5
|
|
22
22
|
with:
|
|
23
23
|
path: vendor/bundle
|
|
24
24
|
key: ${{ runner.os }}-rubocop-${{ hashFiles('**/Gemfile.lock') }}
|
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.
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
@@ -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
|
-
|
|
27
|
-
|
|
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,
|
|
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,
|
|
79
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
346
|
+
if isolation_option.count != 1
|
|
293
347
|
else
|
|
294
|
-
raise "Unsupported isolation level: #{
|
|
295
|
-
[:serializable, :repeatable_read, :read_only, :buffered_mutations, :pdml
|
|
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 #{
|
|
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(?:[
|
|
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 :
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/spanner_client_ext.rb
CHANGED
|
@@ -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.
|
|
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
|