Skip to content

Commit

Permalink
docs: add sample for read-only transactions (#533)
Browse files Browse the repository at this point in the history
* docs: add sample for read-only transactions

Adds a sample and documentation for read-only transactions.

Fixes #493

* chore: run two read-only transactions in test

* chore: remove GetSession requests

* chore: remove unused import
  • Loading branch information
olavloite authored Dec 9, 2024
1 parent 6c3cb42 commit d2d72b6
Show file tree
Hide file tree
Showing 5 changed files with 229 additions and 5 deletions.
13 changes: 8 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,9 @@ ReadOnly transactions
~~~~~~~~~~~~~~~~~~~~~

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

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

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

Stale reads
~~~~~~~~~~~
Expand Down
5 changes: 5 additions & 0 deletions samples/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ def transaction(session):
_sample(session)


@nox.session()
def read_only_transaction(session):
_sample(session)


@nox.session()
def _all_samples(session):
_sample(session)
Expand Down
64 changes: 64 additions & 0 deletions samples/read_only_transaction_sample.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright 2024 Google LLC All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import datetime
import uuid

from sqlalchemy import create_engine, Engine
from sqlalchemy.orm import Session

from sample_helper import run_sample
from model import Singer, Concert, Venue


# Shows how to execute a read-only transaction on Spanner using SQLAlchemy.
def read_only_transaction_sample():
engine = create_engine(
"spanner:///projects/sample-project/"
"instances/sample-instance/"
"databases/sample-database",
echo=True,
)
# First insert a few test rows that can be queried in a read-only transaction.
insert_test_data(engine)

# Create a session that uses a read-only transaction.
# Read-only transactions do not take locks, and are therefore preferred
# above read/write transactions for workloads that only read data on Spanner.
with Session(engine.execution_options(read_only=True)) as session:
print("Singers ordered by last name")
singers = session.query(Singer).order_by(Singer.last_name).all()
for singer in singers:
print("Singer: ", singer.full_name)

print()
print("Singers ordered by first name")
singers = session.query(Singer).order_by(Singer.first_name).all()
for singer in singers:
print("Singer: ", singer.full_name)


def insert_test_data(engine: Engine):
with Session(engine) as session:
session.add_all(
[
Singer(id=str(uuid.uuid4()), first_name="John", last_name="Doe"),
Singer(id=str(uuid.uuid4()), first_name="Jane", last_name="Doe"),
]
)
session.commit()


if __name__ == "__main__":
run_sample(read_only_transaction_sample)
33 changes: 33 additions & 0 deletions test/mockserver_tests/read_only_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright 2024 Google LLC All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from sqlalchemy import String, BigInteger, Sequence, TextClause
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column


class Base(DeclarativeBase):
pass


class Singer(Base):
__tablename__ = "singers"
id: Mapped[int] = mapped_column(
BigInteger,
Sequence("singer_id"),
server_default=TextClause("GET_NEXT_SEQUENCE_VALUE(SEQUENCE singer_id)"),
primary_key=True,
)
name: Mapped[str] = mapped_column(String)
119 changes: 119 additions & 0 deletions test/mockserver_tests/test_read_only_transaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Copyright 2024 Google LLC All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.testing import eq_, is_instance_of
from google.cloud.spanner_v1 import (
FixedSizePool,
BatchCreateSessionsRequest,
ExecuteSqlRequest,
BeginTransactionRequest,
TransactionOptions,
)
from test.mockserver_tests.mock_server_test_base import MockServerTestBase
from test.mockserver_tests.mock_server_test_base import add_result
import google.cloud.spanner_v1.types.type as spanner_type
import google.cloud.spanner_v1.types.result_set as result_set


class TestReadOnlyTransaction(MockServerTestBase):
def test_read_only_transaction(self):
from test.mockserver_tests.read_only_model import Singer

add_singer_query_result("SELECT singers.id, singers.name \n" + "FROM singers")
engine = create_engine(
"spanner:///projects/p/instances/i/databases/d",
echo=True,
connect_args={"client": self.client, "pool": FixedSizePool(size=10)},
)

for i in range(2):
with Session(engine.execution_options(read_only=True)) as session:
# Execute two queries in a read-only transaction.
session.scalars(select(Singer)).all()
session.scalars(select(Singer)).all()

# Verify the requests that we got.
requests = self.spanner_service.requests
eq_(7, len(requests))
is_instance_of(requests[0], BatchCreateSessionsRequest)
is_instance_of(requests[1], BeginTransactionRequest)
is_instance_of(requests[2], ExecuteSqlRequest)
is_instance_of(requests[3], ExecuteSqlRequest)
is_instance_of(requests[4], BeginTransactionRequest)
is_instance_of(requests[5], ExecuteSqlRequest)
is_instance_of(requests[6], ExecuteSqlRequest)
# Verify that the transaction is a read-only transaction.
for index in [1, 4]:
begin_request: BeginTransactionRequest = requests[index]
eq_(
TransactionOptions(
dict(
read_only=TransactionOptions.ReadOnly(
dict(
strong=True,
return_read_timestamp=True,
)
)
)
),
begin_request.options,
)


def add_singer_query_result(sql: str):
result = result_set.ResultSet(
dict(
metadata=result_set.ResultSetMetadata(
dict(
row_type=spanner_type.StructType(
dict(
fields=[
spanner_type.StructType.Field(
dict(
name="singers_id",
type=spanner_type.Type(
dict(code=spanner_type.TypeCode.INT64)
),
)
),
spanner_type.StructType.Field(
dict(
name="singers_name",
type=spanner_type.Type(
dict(code=spanner_type.TypeCode.STRING)
),
)
),
]
)
)
)
),
)
)
result.rows.extend(
[
(
"1",
"Jane Doe",
),
(
"2",
"John Doe",
),
]
)
add_result(sql, result)

0 comments on commit d2d72b6

Please sign in to comment.