mongo-python-driver/pymongo/client_session.py
2018-04-16 09:57:45 -04:00

420 lines
15 KiB
Python

# Copyright 2017 MongoDB, Inc.
#
# 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.
"""Logical sessions for ordering sequential operations.
Requires MongoDB 3.6.
.. versionadded:: 3.6
Causally Consistent Reads
=========================
.. code-block:: python
with client.start_session(causal_consistency=True) as session:
collection = client.db.collection
collection.update_one({'_id': 1}, {'$set': {'x': 10}}, session=session)
secondary_c = collection.with_options(
read_preference=ReadPreference.SECONDARY)
# A secondary read waits for replication of the write.
secondary_c.find_one({'_id': 1}, session=session)
If `causal_consistency` is True (the default), read operations that use
the session are causally after previous read and write operations. Using a
causally consistent session, an application can read its own writes and is
guaranteed monotonic reads, even when reading from replica set secondaries.
.. mongodoc:: causal-consistency
Classes
=======
"""
import collections
import uuid
from bson.binary import Binary
from bson.int64 import Int64
from bson.py3compat import abc
from bson.timestamp import Timestamp
from pymongo import monotonic
from pymongo.errors import InvalidOperation
class SessionOptions(object):
"""Options for a new :class:`ClientSession`.
:Parameters:
- `causal_consistency` (optional): If True (the default), read
operations are causally ordered within the session.
- `auto_start_transaction` (optional): If True, any operation using
the session begins a transaction if none is in progress.
"""
# TODO: accept a TransactionOptions.
def __init__(self,
causal_consistency=True,
auto_start_transaction=False):
self._causal_consistency = causal_consistency
self._auto_start_transaction = auto_start_transaction
@property
def causal_consistency(self):
"""Whether causal consistency is configured."""
return self._causal_consistency
@property
def auto_start_transaction(self):
"""Whether the session is configured to always start a transaction."""
return self._auto_start_transaction
class TransactionOptions(object):
"""Options for :meth:`ClientSession.start_transaction`.
:Parameters:
- `read_concern`: The :class:`~read_concern.ReadConcern` to use for this
transaction.
- `write_concern`: The :class:`~write_concern.WriteConcern` to use for
this transaction.
"""
def __init__(self, read_concern=None, write_concern=None):
# TODO: validate arguments.
self._read_concern = read_concern
self._write_concern = write_concern
@property
def read_concern(self):
"""This transaction's :class:`~read_concern.ReadConcern`."""
return self._read_concern
@property
def write_concern(self):
"""This transaction's :class:`~write_concern.WriteConcern`."""
return self._write_concern
class ClientSession(object):
"""A session for ordering sequential operations."""
def __init__(self, client, server_session, options, authset):
# A MongoClient, a _ServerSession, a SessionOptions, and a set.
self._client = client
self._server_session = server_session
self._options = options
self._authset = authset
self._cluster_time = None
self._operation_time = None
self._current_txn_read_pref = None
self._current_txn_address = None
if self.options.auto_start_transaction:
# TODO: Get transaction options from self.options.
self._current_transaction_opts = TransactionOptions()
else:
self._current_transaction_opts = None
def end_session(self):
"""Finish this session. If a transaction has started, abort it.
It is an error to use the session or any derived
:class:`~pymongo.database.Database`,
:class:`~pymongo.collection.Collection`, or
:class:`~pymongo.cursor.Cursor` after the session has ended.
"""
self._end_session(lock=True, abort_txn=True)
def _end_session(self, lock, abort_txn):
if self._server_session is not None:
try:
if self._current_transaction_opts is not None:
if abort_txn:
self.abort_transaction()
else:
self.commit_transaction()
finally:
self._client._return_server_session(self._server_session, lock)
self._server_session = None
self._current_transaction_opts = None
def _check_ended(self):
if self._server_session is None:
raise InvalidOperation("Cannot use ended session")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Abort when exiting with an exception, otherwise commit.
# TODO: test and document this.
self._end_session(lock=True, abort_txn=exc_val is not None)
@property
def client(self):
"""The :class:`~pymongo.mongo_client.MongoClient` this session was
created from.
"""
return self._client
@property
def options(self):
"""The :class:`SessionOptions` this session was created with."""
return self._options
@property
def session_id(self):
"""A BSON document, the opaque server session identifier."""
self._check_ended()
return self._server_session.session_id
@property
def cluster_time(self):
"""The cluster time returned by the last operation executed
in this session.
"""
return self._cluster_time
@property
def operation_time(self):
"""The operation time returned by the last operation executed
in this session.
"""
return self._operation_time
def start_transaction(self, **kwargs):
"""Start a multi-statement transaction.
Takes the same arguments as :class:`TransactionOptions`.
Do not use this method if the session is configured to automatically
start a transaction.
"""
self._check_ended()
if self._current_transaction_opts is not None:
raise InvalidOperation("Transaction already in progress")
self._current_transaction_opts = TransactionOptions(**kwargs)
self._server_session.statement_id = 0
def commit_transaction(self):
"""Commit a multi-statement transaction."""
self._finish_transaction("commitTransaction")
def abort_transaction(self):
"""Abort a multi-statement transaction."""
self._finish_transaction("abortTransaction")
def _finish_transaction(self, command_name):
self._check_ended()
if self._current_transaction_opts is None:
raise InvalidOperation("No transaction started")
try:
if self._server_session.statement_id == 0:
# Not really started.
self._server_session._transaction_id += 1
return
# TODO: retryable. And it's weird to pass parse_write_concern_error
# from outside database.py.
self._client.admin.command(
command_name,
txnNumber=self._server_session.transaction_id,
stmtId=self._server_session.statement_id,
session=self,
write_concern=self._current_transaction_opts.write_concern,
read_preference=self._current_txn_read_pref,
parse_write_concern_error=True)
finally:
self._server_session.reset_transaction()
self._current_transaction_opts = None
self._current_txn_address = None
self._current_txn_read_pref = None
def _advance_cluster_time(self, cluster_time):
"""Internal cluster time helper."""
if self._cluster_time is None:
self._cluster_time = cluster_time
elif cluster_time is not None:
if cluster_time["clusterTime"] > self._cluster_time["clusterTime"]:
self._cluster_time = cluster_time
def advance_cluster_time(self, cluster_time):
"""Update the cluster time for this session.
:Parameters:
- `cluster_time`: The
:data:`~pymongo.client_session.ClientSession.cluster_time` from
another `ClientSession` instance.
"""
if not isinstance(cluster_time, abc.Mapping):
raise TypeError(
"cluster_time must be a subclass of collections.Mapping")
if not isinstance(cluster_time.get("clusterTime"), Timestamp):
raise ValueError("Invalid cluster_time")
self._advance_cluster_time(cluster_time)
def _advance_operation_time(self, operation_time):
"""Internal operation time helper."""
if self._operation_time is None:
self._operation_time = operation_time
elif operation_time is not None:
if operation_time > self._operation_time:
self._operation_time = operation_time
def advance_operation_time(self, operation_time):
"""Update the operation time for this session.
:Parameters:
- `operation_time`: The
:data:`~pymongo.client_session.ClientSession.operation_time` from
another `ClientSession` instance.
"""
if not isinstance(operation_time, Timestamp):
raise TypeError("operation_time must be an instance "
"of bson.timestamp.Timestamp")
self._advance_operation_time(operation_time)
@property
def has_ended(self):
"""True if this session is finished."""
return self._server_session is None
@property
def in_transaction(self):
"""True if this session has an active multi-statement transaction."""
return self._current_transaction_opts is not None
def _pin_server_address(self, address):
assert self._current_txn_address is None, "Transaction already pinned"
self._current_txn_address = address
def _apply_to(self, command, is_retryable, read_preference):
self._check_ended()
if self.options.auto_start_transaction and not self.in_transaction:
self.start_transaction()
self._server_session.last_use = monotonic.time()
command['lsid'] = self._server_session.session_id
if is_retryable:
self._server_session._transaction_id += 1
command['txnNumber'] = self._server_session.transaction_id
return
if self._current_transaction_opts:
if self._server_session.statement_id == 0:
# First statement begins a new transaction.
self._current_txn_read_pref = read_preference
self._server_session._transaction_id += 1
command['readConcern'] = {'level': 'snapshot'}
command['autocommit'] = False
elif read_preference != self._current_txn_read_pref:
raise InvalidOperation('Transaction readPreference changed')
command['txnNumber'] = self._server_session.transaction_id
# TODO: Allow stmtId for find/getMore, SERVER-33213.
name = next(iter(command))
if name not in ('find', 'getMore'):
command['stmtId'] = self._server_session.statement_id
self._server_session.statement_id += 1
def _advance_statement_id(self, n):
self._check_ended()
self._server_session.advance_statement_id(n)
def _retry_transaction_id(self):
self._check_ended()
self._server_session.retry_transaction_id()
class _ServerSession(object):
def __init__(self):
# Ensure id is type 4, regardless of CodecOptions.uuid_representation.
self.session_id = {'id': Binary(uuid.uuid4().bytes, 4)}
self.last_use = monotonic.time()
self._transaction_id = 0
self.statement_id = 0
def timed_out(self, session_timeout_minutes):
idle_seconds = monotonic.time() - self.last_use
# Timed out if we have less than a minute to live.
return idle_seconds > (session_timeout_minutes - 1) * 60
def advance_statement_id(self, n):
# Every command advances the statement id by 1 already.
self.statement_id += (n - 1)
@property
def transaction_id(self):
"""Positive 64-bit integer."""
return Int64(self._transaction_id)
def reset_transaction(self):
self.statement_id = 0
def retry_transaction_id(self):
self._transaction_id -= 1
class _ServerSessionPool(collections.deque):
"""Pool of _ServerSession objects.
This class is not thread-safe, access it while holding the Topology lock.
"""
def pop_all(self):
ids = []
while self:
ids.append(self.pop().session_id)
return ids
def get_server_session(self, session_timeout_minutes):
# Although the Driver Sessions Spec says we only clear stale sessions
# in return_server_session, PyMongo can't take a lock when returning
# sessions from a __del__ method (like in Cursor.__die), so it can't
# clear stale sessions there. In case many sessions were returned via
# __del__, check for stale sessions here too.
self._clear_stale(session_timeout_minutes)
# The most recently used sessions are on the left.
while self:
s = self.popleft()
if not s.timed_out(session_timeout_minutes):
return s
return _ServerSession()
def return_server_session(self, server_session, session_timeout_minutes):
self._clear_stale(session_timeout_minutes)
if not server_session.timed_out(session_timeout_minutes):
self.appendleft(server_session)
def return_server_session_no_lock(self, server_session):
self.appendleft(server_session)
def _clear_stale(self, session_timeout_minutes):
# Clear stale sessions. The least recently used are on the right.
while self:
if self[-1].timed_out(session_timeout_minutes):
self.pop()
else:
# The remaining sessions also haven't timed out.
break