diff --git a/README.rst b/README.rst index 5c39e139..852bdc5c 100644 --- a/README.rst +++ b/README.rst @@ -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: @@ -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 +`__ +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 ~~~~~~~~~~~ diff --git a/samples/noxfile.py b/samples/noxfile.py index b103fd77..5011e971 100644 --- a/samples/noxfile.py +++ b/samples/noxfile.py @@ -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) diff --git a/samples/read_only_transaction_sample.py b/samples/read_only_transaction_sample.py new file mode 100644 index 00000000..35ef84e7 --- /dev/null +++ b/samples/read_only_transaction_sample.py @@ -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) diff --git a/test/mockserver_tests/read_only_model.py b/test/mockserver_tests/read_only_model.py new file mode 100644 index 00000000..b76cdd3f --- /dev/null +++ b/test/mockserver_tests/read_only_model.py @@ -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) diff --git a/test/mockserver_tests/test_read_only_transaction.py b/test/mockserver_tests/test_read_only_transaction.py new file mode 100644 index 00000000..18abf69f --- /dev/null +++ b/test/mockserver_tests/test_read_only_transaction.py @@ -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)