Skip to content

Commit e2ec1e1

Browse files
tseaverlandrito
authored andcommitted
Implement multi-use snapshots (googleapis#3615)
1 parent 1c68bda commit e2ec1e1

File tree

11 files changed

+803
-530
lines changed

11 files changed

+803
-530
lines changed

spanner/google/cloud/spanner/database.py

Lines changed: 13 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -380,8 +380,7 @@ def batch(self):
380380
"""
381381
return BatchCheckout(self)
382382

383-
def snapshot(self, read_timestamp=None, min_read_timestamp=None,
384-
max_staleness=None, exact_staleness=None):
383+
def snapshot(self, **kw):
385384
"""Return an object which wraps a snapshot.
386385
387386
The wrapper *must* be used as a context manager, with the snapshot
@@ -390,38 +389,15 @@ def snapshot(self, read_timestamp=None, min_read_timestamp=None,
390389
See
391390
https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.TransactionOptions.ReadOnly
392391
393-
If no options are passed, reads will use the ``strong`` model, reading
394-
at a timestamp where all previously committed transactions are visible.
395-
396-
:type read_timestamp: :class:`datetime.datetime`
397-
:param read_timestamp: Execute all reads at the given timestamp.
398-
399-
:type min_read_timestamp: :class:`datetime.datetime`
400-
:param min_read_timestamp: Execute all reads at a
401-
timestamp >= ``min_read_timestamp``.
402-
403-
:type max_staleness: :class:`datetime.timedelta`
404-
:param max_staleness: Read data at a
405-
timestamp >= NOW - ``max_staleness`` seconds.
406-
407-
:type exact_staleness: :class:`datetime.timedelta`
408-
:param exact_staleness: Execute all reads at a timestamp that is
409-
``exact_staleness`` old.
410-
411-
:rtype: :class:`~google.cloud.spanner.snapshot.Snapshot`
412-
:returns: a snapshot bound to this session
413-
:raises: :exc:`ValueError` if the session has not yet been created.
392+
:type kw: dict
393+
:param kw:
394+
Passed through to
395+
:class:`~google.cloud.spanner.snapshot.Snapshot` constructor.
414396
415397
:rtype: :class:`~google.cloud.spanner.database.SnapshotCheckout`
416398
:returns: new wrapper
417399
"""
418-
return SnapshotCheckout(
419-
self,
420-
read_timestamp=read_timestamp,
421-
min_read_timestamp=min_read_timestamp,
422-
max_staleness=max_staleness,
423-
exact_staleness=exact_staleness,
424-
)
400+
return SnapshotCheckout(self, **kw)
425401

426402

427403
class BatchCheckout(object):
@@ -467,40 +443,20 @@ class SnapshotCheckout(object):
467443
:type database: :class:`~google.cloud.spannder.database.Database`
468444
:param database: database to use
469445
470-
:type read_timestamp: :class:`datetime.datetime`
471-
:param read_timestamp: Execute all reads at the given timestamp.
472-
473-
:type min_read_timestamp: :class:`datetime.datetime`
474-
:param min_read_timestamp: Execute all reads at a
475-
timestamp >= ``min_read_timestamp``.
476-
477-
:type max_staleness: :class:`datetime.timedelta`
478-
:param max_staleness: Read data at a
479-
timestamp >= NOW - ``max_staleness`` seconds.
480-
481-
:type exact_staleness: :class:`datetime.timedelta`
482-
:param exact_staleness: Execute all reads at a timestamp that is
483-
``exact_staleness`` old.
446+
:type kw: dict
447+
:param kw:
448+
Passed through to
449+
:class:`~google.cloud.spanner.snapshot.Snapshot` constructor.
484450
"""
485-
def __init__(self, database, read_timestamp=None, min_read_timestamp=None,
486-
max_staleness=None, exact_staleness=None):
451+
def __init__(self, database, **kw):
487452
self._database = database
488453
self._session = None
489-
self._read_timestamp = read_timestamp
490-
self._min_read_timestamp = min_read_timestamp
491-
self._max_staleness = max_staleness
492-
self._exact_staleness = exact_staleness
454+
self._kw = kw
493455

494456
def __enter__(self):
495457
"""Begin ``with`` block."""
496458
session = self._session = self._database._pool.get()
497-
return Snapshot(
498-
session,
499-
read_timestamp=self._read_timestamp,
500-
min_read_timestamp=self._min_read_timestamp,
501-
max_staleness=self._max_staleness,
502-
exact_staleness=self._exact_staleness,
503-
)
459+
return Snapshot(session, **self._kw)
504460

505461
def __exit__(self, exc_type, exc_val, exc_tb):
506462
"""End ``with`` block."""

spanner/google/cloud/spanner/session.py

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -139,30 +139,15 @@ def delete(self):
139139
raise NotFound(self.name)
140140
raise
141141

142-
def snapshot(self, read_timestamp=None, min_read_timestamp=None,
143-
max_staleness=None, exact_staleness=None):
142+
def snapshot(self, **kw):
144143
"""Create a snapshot to perform a set of reads with shared staleness.
145144
146145
See
147146
https://cloud.google.com/spanner/reference/rpc/google.spanner.v1#google.spanner.v1.TransactionOptions.ReadOnly
148147
149-
If no options are passed, reads will use the ``strong`` model, reading
150-
at a timestamp where all previously committed transactions are visible.
151-
152-
:type read_timestamp: :class:`datetime.datetime`
153-
:param read_timestamp: Execute all reads at the given timestamp.
154-
155-
:type min_read_timestamp: :class:`datetime.datetime`
156-
:param min_read_timestamp: Execute all reads at a
157-
timestamp >= ``min_read_timestamp``.
158-
159-
:type max_staleness: :class:`datetime.timedelta`
160-
:param max_staleness: Read data at a
161-
timestamp >= NOW - ``max_staleness`` seconds.
162-
163-
:type exact_staleness: :class:`datetime.timedelta`
164-
:param exact_staleness: Execute all reads at a timestamp that is
165-
``exact_staleness`` old.
148+
:type kw: dict
149+
:param kw: Passed through to
150+
:class:`~google.cloud.spanner.snapshot.Snapshot` ctor.
166151
167152
:rtype: :class:`~google.cloud.spanner.snapshot.Snapshot`
168153
:returns: a snapshot bound to this session
@@ -171,11 +156,7 @@ def snapshot(self, read_timestamp=None, min_read_timestamp=None,
171156
if self._session_id is None:
172157
raise ValueError("Session has not been created.")
173158

174-
return Snapshot(self,
175-
read_timestamp=read_timestamp,
176-
min_read_timestamp=min_read_timestamp,
177-
max_staleness=max_staleness,
178-
exact_staleness=exact_staleness)
159+
return Snapshot(self, **kw)
179160

180161
def read(self, table, columns, keyset, index='', limit=0,
181162
resume_token=b''):
@@ -292,7 +273,7 @@ def run_in_transaction(self, func, *args, **kw):
292273
txn = self.transaction()
293274
else:
294275
txn = self._transaction
295-
if txn._id is None:
276+
if txn._transaction_id is None:
296277
txn.begin()
297278
try:
298279
func(txn, *args, **kw)

spanner/google/cloud/spanner/snapshot.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ class _SnapshotBase(_SessionWrapper):
3434
:type session: :class:`~google.cloud.spanner.session.Session`
3535
:param session: the session used to perform the commit
3636
"""
37+
_multi_use = False
38+
_transaction_id = None
39+
_read_request_count = 0
40+
3741
def _make_txn_selector(self): # pylint: disable=redundant-returns-doc
3842
"""Helper for :meth:`read` / :meth:`execute_sql`.
3943
@@ -70,7 +74,15 @@ def read(self, table, columns, keyset, index='', limit=0,
7074
7175
:rtype: :class:`~google.cloud.spanner.streamed.StreamedResultSet`
7276
:returns: a result set instance which can be used to consume rows.
77+
:raises: ValueError for reuse of single-use snapshots, or if a
78+
transaction ID is pending for multiple-use snapshots.
7379
"""
80+
if self._read_request_count > 0:
81+
if not self._multi_use:
82+
raise ValueError("Cannot re-use single-use snapshot.")
83+
if self._transaction_id is None:
84+
raise ValueError("Transaction ID pending.")
85+
7486
database = self._session._database
7587
api = database.spanner_api
7688
options = _options_with_prefix(database.name)
@@ -81,7 +93,12 @@ def read(self, table, columns, keyset, index='', limit=0,
8193
transaction=transaction, index=index, limit=limit,
8294
resume_token=resume_token, options=options)
8395

84-
return StreamedResultSet(iterator)
96+
self._read_request_count += 1
97+
98+
if self._multi_use:
99+
return StreamedResultSet(iterator, source=self)
100+
else:
101+
return StreamedResultSet(iterator)
85102

86103
def execute_sql(self, sql, params=None, param_types=None, query_mode=None,
87104
resume_token=b''):
@@ -109,7 +126,15 @@ def execute_sql(self, sql, params=None, param_types=None, query_mode=None,
109126
110127
:rtype: :class:`~google.cloud.spanner.streamed.StreamedResultSet`
111128
:returns: a result set instance which can be used to consume rows.
129+
:raises: ValueError for reuse of single-use snapshots, or if a
130+
transaction ID is pending for multiple-use snapshots.
112131
"""
132+
if self._read_request_count > 0:
133+
if not self._multi_use:
134+
raise ValueError("Cannot re-use single-use snapshot.")
135+
if self._transaction_id is None:
136+
raise ValueError("Transaction ID pending.")
137+
113138
if params is not None:
114139
if param_types is None:
115140
raise ValueError(
@@ -128,7 +153,12 @@ def execute_sql(self, sql, params=None, param_types=None, query_mode=None,
128153
transaction=transaction, params=params_pb, param_types=param_types,
129154
query_mode=query_mode, resume_token=resume_token, options=options)
130155

131-
return StreamedResultSet(iterator)
156+
self._read_request_count += 1
157+
158+
if self._multi_use:
159+
return StreamedResultSet(iterator, source=self)
160+
else:
161+
return StreamedResultSet(iterator)
132162

133163

134164
class Snapshot(_SnapshotBase):
@@ -157,9 +187,16 @@ class Snapshot(_SnapshotBase):
157187
:type exact_staleness: :class:`datetime.timedelta`
158188
:param exact_staleness: Execute all reads at a timestamp that is
159189
``exact_staleness`` old.
190+
191+
:type multi_use: :class:`bool`
192+
:param multi_use: If true, multipl :meth:`read` / :meth:`execute_sql`
193+
calls can be performed with the snapshot in the
194+
context of a read-only transaction, used to ensure
195+
isolation / consistency. Incompatible with
196+
``max_staleness`` and ``min_read_timestamp``.
160197
"""
161198
def __init__(self, session, read_timestamp=None, min_read_timestamp=None,
162-
max_staleness=None, exact_staleness=None):
199+
max_staleness=None, exact_staleness=None, multi_use=False):
163200
super(Snapshot, self).__init__(session)
164201
opts = [
165202
read_timestamp, min_read_timestamp, max_staleness, exact_staleness]
@@ -168,14 +205,24 @@ def __init__(self, session, read_timestamp=None, min_read_timestamp=None,
168205
if len(flagged) > 1:
169206
raise ValueError("Supply zero or one options.")
170207

208+
if multi_use:
209+
if min_read_timestamp is not None or max_staleness is not None:
210+
raise ValueError(
211+
"'multi_use' is incompatible with "
212+
"'min_read_timestamp' / 'max_staleness'")
213+
171214
self._strong = len(flagged) == 0
172215
self._read_timestamp = read_timestamp
173216
self._min_read_timestamp = min_read_timestamp
174217
self._max_staleness = max_staleness
175218
self._exact_staleness = exact_staleness
219+
self._multi_use = multi_use
176220

177221
def _make_txn_selector(self):
178222
"""Helper for :meth:`read`."""
223+
if self._transaction_id is not None:
224+
return TransactionSelector(id=self._transaction_id)
225+
179226
if self._read_timestamp:
180227
key = 'read_timestamp'
181228
value = _datetime_to_pb_timestamp(self._read_timestamp)
@@ -194,4 +241,34 @@ def _make_txn_selector(self):
194241

195242
options = TransactionOptions(
196243
read_only=TransactionOptions.ReadOnly(**{key: value}))
197-
return TransactionSelector(single_use=options)
244+
245+
if self._multi_use:
246+
return TransactionSelector(begin=options)
247+
else:
248+
return TransactionSelector(single_use=options)
249+
250+
def begin(self):
251+
"""Begin a transaction on the database.
252+
253+
:rtype: bytes
254+
:returns: the ID for the newly-begun transaction.
255+
:raises: ValueError if the transaction is already begun, committed,
256+
or rolled back.
257+
"""
258+
if not self._multi_use:
259+
raise ValueError("Cannot call 'begin' single-use snapshots")
260+
261+
if self._transaction_id is not None:
262+
raise ValueError("Read-only transaction already begun")
263+
264+
if self._read_request_count > 0:
265+
raise ValueError("Read-only transaction already pending")
266+
267+
database = self._session._database
268+
api = database.spanner_api
269+
options = _options_with_prefix(database.name)
270+
txn_selector = self._make_txn_selector()
271+
response = api.begin_transaction(
272+
self._session.name, txn_selector.begin, options=options)
273+
self._transaction_id = response.id
274+
return self._transaction_id

spanner/google/cloud/spanner/streamed.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ class StreamedResultSet(object):
3232
Iterator yielding
3333
:class:`google.cloud.proto.spanner.v1.result_set_pb2.PartialResultSet`
3434
instances.
35+
36+
:type source: :class:`~google.cloud.spanner.snapshot.Snapshot`
37+
:param source: Snapshot from which the result set was fetched.
3538
"""
36-
def __init__(self, response_iterator):
39+
def __init__(self, response_iterator, source=None):
3740
self._response_iterator = response_iterator
3841
self._rows = [] # Fully-processed rows
3942
self._counter = 0 # Counter for processed responses
@@ -42,6 +45,7 @@ def __init__(self, response_iterator):
4245
self._resume_token = None # To resume from last received PRS
4346
self._current_row = [] # Accumulated values for incomplete row
4447
self._pending_chunk = None # Incomplete value
48+
self._source = source # Source snapshot
4549

4650
@property
4751
def rows(self):
@@ -130,7 +134,11 @@ def consume_next(self):
130134
self._resume_token = response.resume_token
131135

132136
if self._metadata is None: # first response
133-
self._metadata = response.metadata
137+
metadata = self._metadata = response.metadata
138+
139+
source = self._source
140+
if source is not None and source._transaction_id is None:
141+
source._transaction_id = metadata.transaction.id
134142

135143
if response.HasField('stats'): # last response
136144
self._stats = response.stats

0 commit comments

Comments
 (0)