Skip to content

Commit bf47d59

Browse files
committed
docs: add sample for read-only transactions
Adds a sample and documentation for read-only transactions. Fixes #493
1 parent 6ff12ec commit bf47d59

File tree

5 files changed

+198
-5
lines changed

5 files changed

+198
-5
lines changed

README.rst

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,9 @@ ReadOnly transactions
344344
~~~~~~~~~~~~~~~~~~~~~
345345

346346
By default, transactions produced by a Spanner connection are in
347-
ReadWrite mode. However, some applications require an ability to grant
348-
ReadOnly access to users/methods; for these cases Spanner dialect
347+
ReadWrite mode. However, workloads that only read data perform better
348+
if they use read-only transactions, as Spanner does not need to take
349+
locks for the data that is read; for these cases, the Spanner dialect
349350
supports the ``read_only`` execution option, which switches a connection
350351
into ReadOnly mode:
351352

@@ -354,11 +355,13 @@ into ReadOnly mode:
354355
with engine.connect().execution_options(read_only=True) as connection:
355356
connection.execute(select(["*"], from_obj=table)).fetchall()
356357
357-
Note that execution options are applied lazily - on the ``execute()``
358-
method call, right before it.
358+
See the `Read-only transaction sample
359+
<https://github.com/googleapis/python-spanner-sqlalchemy/blob/-/samples/read_only_transaction_sample.py>`__
360+
for a concrete example.
359361

360362
ReadOnly/ReadWrite mode of a connection can't be changed while a
361-
transaction is in progress - first you must commit or rollback it.
363+
transaction is in progress - you must commit or rollback the current
364+
transaction before changing the mode.
362365

363366
Stale reads
364367
~~~~~~~~~~~

samples/noxfile.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ def transaction(session):
5757
_sample(session)
5858

5959

60+
@nox.session()
61+
def read_only_transaction(session):
62+
_sample(session)
63+
64+
6065
@nox.session()
6166
def _all_samples(session):
6267
_sample(session)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright 2024 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import datetime
16+
import uuid
17+
18+
from sqlalchemy import create_engine, Engine
19+
from sqlalchemy.orm import Session
20+
21+
from sample_helper import run_sample
22+
from model import Singer, Concert, Venue
23+
24+
25+
# Shows how to execute a read-only transaction on Spanner using SQLAlchemy.
26+
def read_only_transaction_sample():
27+
engine = create_engine(
28+
"spanner:///projects/sample-project/"
29+
"instances/sample-instance/"
30+
"databases/sample-database",
31+
echo=True,
32+
)
33+
# First insert a few test rows that can be queried in a read-only transaction.
34+
insert_test_data(engine)
35+
36+
# Create a session that uses a read-only transaction.
37+
# Read-only transactions do not take locks, and are therefore preferred
38+
# above read/write transactions for workloads that only read data on Spanner.
39+
with Session(engine.execution_options(read_only=True)) as session:
40+
print("Singers ordered by last name")
41+
singers = session.query(Singer).order_by(Singer.last_name).all()
42+
for singer in singers:
43+
print("Singer: ", singer.full_name)
44+
45+
print()
46+
print("Singers ordered by first name")
47+
singers = session.query(Singer).order_by(Singer.first_name).all()
48+
for singer in singers:
49+
print("Singer: ", singer.full_name)
50+
51+
52+
def insert_test_data(engine: Engine):
53+
with Session(engine) as session:
54+
session.add_all(
55+
[
56+
Singer(id=str(uuid.uuid4()), first_name="John", last_name="Doe"),
57+
Singer(id=str(uuid.uuid4()), first_name="Jane", last_name="Doe"),
58+
]
59+
)
60+
session.commit()
61+
62+
63+
if __name__ == "__main__":
64+
run_sample(read_only_transaction_sample)

test/mockserver_tests/bit_reversed_sequence_model.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
from sqlalchemy.orm import DeclarativeBase
1717
from sqlalchemy.orm import Mapped
1818
from sqlalchemy.orm import mapped_column
19+
from test.mockserver_tests.mock_server_test_base import add_result
20+
import google.cloud.spanner_v1.types.type as spanner_type
21+
import google.cloud.spanner_v1.types.result_set as result_set
1922

2023

2124
class Base(DeclarativeBase):
@@ -31,3 +34,49 @@ class Singer(Base):
3134
primary_key=True,
3235
)
3336
name: Mapped[str] = mapped_column(String)
37+
38+
39+
def add_singer_query_result(sql: str):
40+
result = result_set.ResultSet(
41+
dict(
42+
metadata=result_set.ResultSetMetadata(
43+
dict(
44+
row_type=spanner_type.StructType(
45+
dict(
46+
fields=[
47+
spanner_type.StructType.Field(
48+
dict(
49+
name="singers_id",
50+
type=spanner_type.Type(
51+
dict(code=spanner_type.TypeCode.INT64)
52+
),
53+
)
54+
),
55+
spanner_type.StructType.Field(
56+
dict(
57+
name="name",
58+
type=spanner_type.Type(
59+
dict(code=spanner_type.TypeCode.STRING)
60+
),
61+
)
62+
),
63+
]
64+
)
65+
)
66+
)
67+
),
68+
)
69+
)
70+
result.rows.extend(
71+
[
72+
(
73+
"1",
74+
"Jane Doe",
75+
),
76+
(
77+
"2",
78+
"John Doe",
79+
),
80+
]
81+
)
82+
add_result(sql, result)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright 2024 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from sqlalchemy import create_engine
16+
from sqlalchemy.orm import Session
17+
from sqlalchemy.testing import eq_, is_instance_of
18+
from google.cloud.spanner_v1 import (
19+
FixedSizePool,
20+
BatchCreateSessionsRequest,
21+
ExecuteSqlRequest,
22+
GetSessionRequest,
23+
BeginTransactionRequest,
24+
TransactionOptions,
25+
)
26+
27+
from test.mockserver_tests.bit_reversed_sequence_model import add_singer_query_result
28+
from test.mockserver_tests.mock_server_test_base import MockServerTestBase
29+
30+
31+
class TestReadOnlyTransaction(MockServerTestBase):
32+
def test_read_only_transaction(self):
33+
from test.mockserver_tests.bit_reversed_sequence_model import Singer
34+
35+
add_singer_query_result(
36+
"SELECT singers.id AS singers_id, singers.name AS singers_name \n"
37+
"FROM singers"
38+
)
39+
engine = create_engine(
40+
"spanner:///projects/p/instances/i/databases/d",
41+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
42+
)
43+
44+
with Session(engine.execution_options(read_only=True)) as session:
45+
# Execute two queries in a read-only transaction.
46+
session.query(Singer).all()
47+
session.query(Singer).all()
48+
49+
# Verify the requests that we got.
50+
requests = self.spanner_service.requests
51+
eq_(5, len(requests))
52+
is_instance_of(requests[0], BatchCreateSessionsRequest)
53+
# We should get rid of this extra round-trip for GetSession....
54+
is_instance_of(requests[1], GetSessionRequest)
55+
is_instance_of(requests[2], BeginTransactionRequest)
56+
is_instance_of(requests[3], ExecuteSqlRequest)
57+
is_instance_of(requests[4], ExecuteSqlRequest)
58+
# Verify that the transaction is a read-only transaction.
59+
begin_request: BeginTransactionRequest = requests[2]
60+
eq_(
61+
TransactionOptions(
62+
dict(
63+
read_only=TransactionOptions.ReadOnly(
64+
dict(
65+
strong=True,
66+
return_read_timestamp=True,
67+
)
68+
)
69+
)
70+
),
71+
begin_request.options,
72+
)

0 commit comments

Comments
 (0)