Skip to content

Commit 29dd202

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 29dd202

File tree

5 files changed

+227
-5
lines changed

5 files changed

+227
-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)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 String, BigInteger, Sequence, TextClause
16+
from sqlalchemy.orm import DeclarativeBase
17+
from sqlalchemy.orm import Mapped
18+
from sqlalchemy.orm import mapped_column
19+
20+
21+
class Base(DeclarativeBase):
22+
pass
23+
24+
25+
class Singer(Base):
26+
__tablename__ = "singers"
27+
id: Mapped[int] = mapped_column(
28+
BigInteger,
29+
Sequence("singer_id"),
30+
server_default=TextClause("GET_NEXT_SEQUENCE_VALUE(SEQUENCE singer_id)"),
31+
primary_key=True,
32+
)
33+
name: Mapped[str] = mapped_column(String)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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, select
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+
from test.mockserver_tests.mock_server_test_base import MockServerTestBase
27+
from test.mockserver_tests.mock_server_test_base import add_result
28+
import google.cloud.spanner_v1.types.type as spanner_type
29+
import google.cloud.spanner_v1.types.result_set as result_set
30+
31+
32+
class TestReadOnlyTransaction(MockServerTestBase):
33+
def test_read_only_transaction(self):
34+
from test.mockserver_tests.read_only_model import Singer
35+
36+
add_singer_query_result("SELECT singers.id, singers.name \n" + "FROM singers")
37+
engine = create_engine(
38+
"spanner:///projects/p/instances/i/databases/d",
39+
echo=True,
40+
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
41+
)
42+
43+
with Session(engine.execution_options(read_only=True)) as session:
44+
# Execute two queries in a read-only transaction.
45+
session.scalars(select(Singer)).all()
46+
session.scalars(select(Singer)).all()
47+
48+
# Verify the requests that we got.
49+
requests = self.spanner_service.requests
50+
eq_(5, len(requests))
51+
is_instance_of(requests[0], BatchCreateSessionsRequest)
52+
# We should get rid of this extra round-trip for GetSession....
53+
is_instance_of(requests[1], GetSessionRequest)
54+
is_instance_of(requests[2], BeginTransactionRequest)
55+
is_instance_of(requests[3], ExecuteSqlRequest)
56+
is_instance_of(requests[4], ExecuteSqlRequest)
57+
# Verify that the transaction is a read-only transaction.
58+
begin_request: BeginTransactionRequest = requests[2]
59+
eq_(
60+
TransactionOptions(
61+
dict(
62+
read_only=TransactionOptions.ReadOnly(
63+
dict(
64+
strong=True,
65+
return_read_timestamp=True,
66+
)
67+
)
68+
)
69+
),
70+
begin_request.options,
71+
)
72+
73+
74+
def add_singer_query_result(sql: str):
75+
result = result_set.ResultSet(
76+
dict(
77+
metadata=result_set.ResultSetMetadata(
78+
dict(
79+
row_type=spanner_type.StructType(
80+
dict(
81+
fields=[
82+
spanner_type.StructType.Field(
83+
dict(
84+
name="singers_id",
85+
type=spanner_type.Type(
86+
dict(code=spanner_type.TypeCode.INT64)
87+
),
88+
)
89+
),
90+
spanner_type.StructType.Field(
91+
dict(
92+
name="singers_name",
93+
type=spanner_type.Type(
94+
dict(code=spanner_type.TypeCode.STRING)
95+
),
96+
)
97+
),
98+
]
99+
)
100+
)
101+
)
102+
),
103+
)
104+
)
105+
result.rows.extend(
106+
[
107+
(
108+
"1",
109+
"Jane Doe",
110+
),
111+
(
112+
"2",
113+
"John Doe",
114+
),
115+
]
116+
)
117+
add_result(sql, result)

0 commit comments

Comments
 (0)