Skip to content

Commit a72eb33

Browse files
committed
feat: Batch DML support
Add support for Batch DML in read/write transactions. Batch DML reduces the number of round-trips to Spanner when executing multiple writes. The big advantage of using Batch DML over mutations, is that Batch DML does support read-your-writes after the batch has been written.
1 parent 05c1980 commit a72eb33

File tree

16 files changed

+448
-19
lines changed

16 files changed

+448
-19
lines changed

7.0.0

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
5.times do
20+
Singer.create first_name: first_names.sample, last_name: last_names.sample
21+
end
22+
# Data that has been inserted/update using Batch DML can be read in the same
23+
# transaction as the one that added/updated the data. This is different from
24+
# mutations, as mutations do not support read-your-writes.
25+
singers = Singer.all
26+
puts "Inserted #{singers.count} singers in one batch"
27+
end
28+
29+
# Batch DML can also be used to update existing data.
30+
ActiveRecord::Base.transaction do
31+
singers = Singer.all
32+
singers.each do |singer|
33+
singer.picture = Base64.encode64 SecureRandom.alphanumeric(SecureRandom.random_number(10..200))
34+
singer.save
35+
end
36+
puts "Updated #{singers.count} singers in one batch"
37+
end
38+
end
39+
end
40+
41+
42+
Application.run
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
development:
2+
adapter: spanner
3+
emulator_host: localhost:9010
4+
project: test-project
5+
instance: test-instance
6+
database: testdb
7+
pool: 5
8+
timeout: 5000
9+
schema_dump: false
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/active_record/connection_adapters/spanner/database_statements.rb

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ def execute sql, name = nil, binds = []
2323

2424
def internal_exec_query sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false
2525
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-
)
26+
if result
27+
ActiveRecord::Result.new(
28+
result.fields.keys.map(&:to_s), result.rows.map(&:values)
29+
)
30+
else
31+
ActiveRecord::Result.new [], []
32+
end
2933
end
3034

3135
def internal_execute sql, name = "SQL", binds = [],
@@ -72,11 +76,12 @@ def execute_query_or_dml statement_type, sql, name, binds
7276
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
7377
if transaction_required
7478
transaction do
75-
@connection.execute_query sql, params: params, types: types, request_options: request_options
79+
@connection.execute_query sql, params: params, types: types, request_options: request_options,
80+
statement_type: statement_type
7681
end
7782
else
7883
@connection.execute_query sql, params: params, types: types, single_use_selector: selector,
79-
request_options: request_options
84+
request_options: request_options, statement_type: statement_type
8085
end
8186
end
8287
end
@@ -142,9 +147,13 @@ def query sql, name = nil
142147

143148
def exec_query sql, name = "SQL", binds = [], prepare: false # rubocop:disable Lint/UnusedMethodArgument
144149
result = execute sql, name, binds
145-
ActiveRecord::Result.new(
146-
result.fields.keys.map(&:to_s), result.rows.map(&:values)
147-
)
150+
if result.respond_to? :fields
151+
ActiveRecord::Result.new(
152+
result.fields.keys.map(&:to_s), result.rows.map(&:values)
153+
)
154+
else
155+
ActiveRecord::Result.new [], []
156+
end
148157
end
149158

150159
def sql_for_insert sql, pk, binds
@@ -191,6 +200,7 @@ def update arel, name = nil, binds = []
191200

192201
def exec_update sql, name = "SQL", binds = []
193202
result = execute sql, name, binds
203+
return unless result
194204
# Make sure that we consume the entire result stream before trying to get the stats.
195205
# This is required because the ExecuteStreamingSql RPC is also used for (Partitioned) DML,
196206
# and this RPC can return multiple partial result sets for DML as well. Only the last partial
@@ -243,6 +253,9 @@ def transaction requires_new: nil, isolation: nil, joinable: true
243253
retry
244254
end
245255
raise
256+
rescue Google::Cloud::AbortedError => err
257+
sleep(delay_from_aborted(err) || backoff *= 1.3)
258+
retry
246259
end
247260
end
248261

0 commit comments

Comments
 (0)