mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-25 14:53:37 +00:00
2115 lines
90 KiB
Python
2115 lines
90 KiB
Python
"""Abstract classes for Distributed Configuration Store."""
|
|
import abc
|
|
import datetime
|
|
import json
|
|
import logging
|
|
import re
|
|
import time
|
|
|
|
from collections import defaultdict
|
|
from copy import deepcopy
|
|
from random import randint
|
|
from threading import Event, Lock
|
|
from typing import Any, Callable, Collection, Dict, Iterator, List, \
|
|
NamedTuple, Optional, Set, Tuple, Type, TYPE_CHECKING, Union
|
|
from urllib.parse import parse_qsl, urlparse, urlunparse
|
|
|
|
import dateutil.parser
|
|
|
|
from .. import global_config
|
|
from ..dynamic_loader import iter_classes, iter_modules
|
|
from ..exceptions import PatroniFatalException
|
|
from ..tags import Tags
|
|
from ..utils import deep_compare, parse_int, uri
|
|
|
|
if TYPE_CHECKING: # pragma: no cover
|
|
from ..config import Config
|
|
from ..postgresql import Postgresql
|
|
from ..postgresql.mpp import AbstractMPP
|
|
|
|
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def slot_name_from_member_name(member_name: str) -> str:
|
|
"""Translate member name to valid PostgreSQL slot name.
|
|
|
|
.. note::
|
|
PostgreSQL's replication slot names must be valid PostgreSQL names. This function maps the wider space of
|
|
member names to valid PostgreSQL names. Names have their case lowered, dashes and periods common in hostnames
|
|
are replaced with underscores, other characters are encoded as their unicode codepoint. Name is truncated
|
|
to 64 characters. Multiple different member names may map to a single slot name.
|
|
|
|
:param member_name: The string to convert to a slot name.
|
|
|
|
:returns: The string converted using the rules described above.
|
|
"""
|
|
|
|
def replace_char(match: Any) -> str:
|
|
c = match.group(0)
|
|
return '_' if c in '-.' else f"u{ord(c):04d}"
|
|
|
|
slot_name = re.sub('[^a-z0-9_]', replace_char, member_name.lower())
|
|
return slot_name[0:63]
|
|
|
|
|
|
def parse_connection_string(value: str) -> Tuple[str, Union[str, None]]:
|
|
"""Split and rejoin a URL string into a connection URL and an API URL.
|
|
|
|
.. note::
|
|
Original Governor stores connection strings for each cluster members in a following format:
|
|
|
|
postgres://{username}:{password}@{connect_address}/postgres
|
|
|
|
Since each of our patroni instances provides their own REST API endpoint, it's good to store this information
|
|
in DCS along with PostgreSQL connection string. In order to not introduce new keys and be compatible with
|
|
original Governor we decided to extend original connection string in a following way:
|
|
|
|
postgres://{username}:{password}@{connect_address}/postgres?application_name={api_url}
|
|
|
|
This way original Governor could use such connection string as it is, because of feature of ``libpq`` library.
|
|
|
|
:param value: The URL string to split.
|
|
|
|
:returns: the connection string stored in DCS split into two parts, ``conn_url`` and ``api_url``.
|
|
"""
|
|
scheme, netloc, path, params, query, fragment = urlparse(value)
|
|
conn_url = urlunparse((scheme, netloc, path, params, '', fragment))
|
|
api_url = ([v for n, v in parse_qsl(query) if n == 'application_name'] or [None])[0]
|
|
return conn_url, api_url
|
|
|
|
|
|
def dcs_modules() -> List[str]:
|
|
"""Get names of DCS modules, depending on execution environment.
|
|
|
|
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``.
|
|
"""
|
|
if TYPE_CHECKING: # pragma: no cover
|
|
assert isinstance(__package__, str)
|
|
return iter_modules(__package__)
|
|
|
|
|
|
def iter_dcs_classes(
|
|
config: Optional[Union['Config', Dict[str, Any]]] = None
|
|
) -> Iterator[Tuple[str, Type['AbstractDCS']]]:
|
|
"""Attempt to import DCS modules that are present in the given configuration.
|
|
|
|
.. note::
|
|
If a module successfully imports we can assume that all its requirements are installed.
|
|
|
|
:param config: configuration information with possible DCS names as keys. If given, only attempt to import DCS
|
|
modules defined in the configuration. Else, if ``None``, attempt to import any supported DCS module.
|
|
|
|
:returns: an iterator of tuples, each containing the module ``name`` and the imported DCS class object.
|
|
"""
|
|
if TYPE_CHECKING: # pragma: no cover
|
|
assert isinstance(__package__, str)
|
|
return iter_classes(__package__, AbstractDCS, config)
|
|
|
|
|
|
def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
|
|
"""Attempt to load a Distributed Configuration Store from known available implementations.
|
|
|
|
.. note::
|
|
Using the list of available DCS classes returned by :func:`iter_classes` attempt to dynamically
|
|
instantiate the class that implements a DCS using the abstract class :class:`AbstractDCS`.
|
|
|
|
Basic top-level configuration parameters retrieved from *config* are propagated to the DCS specific config
|
|
before being passed to the module DCS class.
|
|
|
|
If no module is found to satisfy configuration then report and log an error. This will cause Patroni to exit.
|
|
|
|
:raises :exc:`PatroniFatalException`: if a load of all available DCS modules have been tried and none succeeded.
|
|
|
|
:param config: object or dictionary with Patroni configuration. This is normally a representation of the main
|
|
Patroni
|
|
|
|
:returns: The first successfully loaded DCS module which is an implementation of :class:`AbstractDCS`.
|
|
"""
|
|
for name, dcs_class in iter_dcs_classes(config):
|
|
# Propagate some parameters from top level of config if defined to the DCS specific config section.
|
|
config[name].update({
|
|
p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
|
|
'patronictl', 'ttl', 'retry_timeout')
|
|
if p in config})
|
|
|
|
from patroni.postgresql.mpp import get_mpp
|
|
return dcs_class(config[name], get_mpp(config))
|
|
|
|
available_implementations = ', '.join(sorted([n for n, _ in iter_dcs_classes()]))
|
|
raise PatroniFatalException("Can not find suitable configuration of distributed configuration store\n"
|
|
f"Available implementations: {available_implementations}")
|
|
|
|
|
|
_Version = Union[int, str]
|
|
_Session = Union[int, float, str, None]
|
|
|
|
|
|
class Member(Tags, NamedTuple('Member',
|
|
[('version', _Version),
|
|
('name', str),
|
|
('session', _Session),
|
|
('data', Dict[str, Any])])):
|
|
"""Immutable object (namedtuple) which represents single member of PostgreSQL cluster.
|
|
|
|
.. note::
|
|
We are using an old-style attribute declaration here because otherwise it is not possible to override
|
|
``__new__`` method in the :class:`RemoteMember` class.
|
|
|
|
.. note::
|
|
These two keys in data are always written to the DCS, but care is taken to maintain consistency and resilience
|
|
from data that is read:
|
|
|
|
``conn_url``: connection string containing host, user and password which could be used to access this member.
|
|
``api_url``: REST API url of patroni instance
|
|
|
|
Consists of the following fields:
|
|
|
|
:ivar version: modification version of a given member key in a Configuration Store.
|
|
:ivar name: name of PostgreSQL cluster member.
|
|
:ivar session: either session id or just ttl in seconds.
|
|
:ivar data: dictionary containing arbitrary data i.e. ``conn_url``, ``api_url``, ``xlog_location``, ``state``,
|
|
``role``, ``tags``, etc...
|
|
"""
|
|
|
|
@staticmethod
|
|
def from_node(version: _Version, name: str, session: _Session, value: str) -> 'Member':
|
|
"""Factory method for instantiating :class:`Member` from a JSON serialised string or object.
|
|
|
|
:param version: modification version of a given member key in a Configuration Store.
|
|
:param name: name of PostgreSQL cluster member.
|
|
:param session: either session id or just ttl in seconds.
|
|
:param value: JSON encoded string containing arbitrary data i.e. ``conn_url``, ``api_url``,
|
|
``xlog_location``, ``state``, ``role``, ``tags``, etc. OR a connection URL
|
|
starting with ``postgres://``.
|
|
|
|
:returns: an :class:`Member` instance built with the given arguments.
|
|
|
|
:Example:
|
|
|
|
>>> Member.from_node(-1, '', '', '{"conn_url": "postgres://foo@bar/postgres"}') is not None
|
|
True
|
|
|
|
>>> Member.from_node(-1, '', '', '{')
|
|
Member(version=-1, name='', session='', data={})
|
|
"""
|
|
if value.startswith('postgres'):
|
|
conn_url, api_url = parse_connection_string(value)
|
|
data = {'conn_url': conn_url, 'api_url': api_url}
|
|
else:
|
|
try:
|
|
data = json.loads(value)
|
|
assert isinstance(data, dict)
|
|
except (AssertionError, TypeError, ValueError):
|
|
data: Dict[str, Any] = {}
|
|
return Member(version, name, session, data)
|
|
|
|
@property
|
|
def conn_url(self) -> Optional[str]:
|
|
"""The ``conn_url`` value from :attr:`~Member.data` if defined or constructed from ``conn_kwargs``."""
|
|
conn_url = self.data.get('conn_url')
|
|
if conn_url:
|
|
return conn_url
|
|
|
|
conn_kwargs = self.data.get('conn_kwargs')
|
|
if conn_kwargs:
|
|
conn_url = uri('postgresql', (conn_kwargs.get('host'), conn_kwargs.get('port', 5432)))
|
|
self.data['conn_url'] = conn_url
|
|
return conn_url
|
|
|
|
return None
|
|
|
|
def conn_kwargs(self, auth: Union[Any, Dict[str, Any], None] = None) -> Dict[str, Any]:
|
|
"""Give keyword arguments used for PostgreSQL connection settings.
|
|
|
|
:param auth: Authentication properties - can be defined as anything supported by the ``psycopg2`` or
|
|
``psycopg`` modules.
|
|
Converts a key of ``username`` to ``user`` if supplied.
|
|
|
|
:returns: A dictionary containing a merge of default parameter keys ``host``, ``port`` and ``dbname``, with
|
|
the contents of :attr:`~Member.data` ``conn_kwargs`` key. If those are not defined will
|
|
parse and reform connection parameters from :attr:`~Member.conn_url`. One of these two attributes
|
|
needs to have data defined to construct the output dictionary. Finally, *auth* parameters are merged
|
|
with the dictionary before returned.
|
|
"""
|
|
defaults = {
|
|
"host": None,
|
|
"port": None,
|
|
"dbname": None
|
|
}
|
|
ret: Optional[Dict[str, Any]] = self.data.get('conn_kwargs')
|
|
if ret:
|
|
defaults.update(ret)
|
|
ret = defaults
|
|
else:
|
|
conn_url = self.conn_url
|
|
if not conn_url:
|
|
return {} # due to the invalid conn_url we don't care about authentication parameters
|
|
r = urlparse(conn_url)
|
|
ret = {
|
|
'host': r.hostname,
|
|
'port': r.port or 5432,
|
|
'dbname': r.path[1:]
|
|
}
|
|
self.data['conn_kwargs'] = ret.copy()
|
|
|
|
# apply any remaining authentication parameters
|
|
if auth and isinstance(auth, dict):
|
|
ret.update({k: v for k, v in auth.items() if v is not None})
|
|
if 'username' in auth:
|
|
ret['user'] = ret.pop('username')
|
|
return ret
|
|
|
|
def get_endpoint_url(self, endpoint: Optional[str] = None) -> str:
|
|
"""Get URL from member :attr:`~Member.api_url` and endpoint.
|
|
|
|
:param endpoint: URL path of REST API.
|
|
|
|
:returns: full URL for this REST API.
|
|
"""
|
|
url = self.api_url or ''
|
|
if endpoint:
|
|
scheme, netloc, _, _, _, _ = urlparse(url)
|
|
url = urlunparse((scheme, netloc, endpoint, '', '', ''))
|
|
return url
|
|
|
|
@property
|
|
def api_url(self) -> Optional[str]:
|
|
"""The ``api_url`` value from :attr:`~Member.data` if defined."""
|
|
return self.data.get('api_url')
|
|
|
|
@property
|
|
def tags(self) -> Dict[str, Any]:
|
|
"""The ``tags`` value from :attr:`~Member.data` if defined, otherwise an empty dictionary."""
|
|
return self.data.get('tags', {})
|
|
|
|
@property
|
|
def clonefrom(self) -> bool:
|
|
"""``True`` if both ``clonefrom`` tag is ``True`` and a connection URL is defined."""
|
|
return super().clonefrom and bool(self.conn_url)
|
|
|
|
@property
|
|
def state(self) -> str:
|
|
"""The ``state`` value of :attr:`~Member.data`."""
|
|
return self.data.get('state', 'unknown')
|
|
|
|
@property
|
|
def is_running(self) -> bool:
|
|
"""``True`` if the member :attr:`~Member.state` is ``running``."""
|
|
return self.state == 'running'
|
|
|
|
@property
|
|
def patroni_version(self) -> Optional[Tuple[int, ...]]:
|
|
"""The ``version`` string value from :attr:`~Member.data` converted to tuple.
|
|
|
|
:Example:
|
|
|
|
>>> Member.from_node(1, '', '', '{"version":"1.2.3"}').patroni_version
|
|
(1, 2, 3)
|
|
|
|
"""
|
|
version = self.data.get('version')
|
|
if version:
|
|
try:
|
|
return tuple(map(int, version.split('.')))
|
|
except Exception:
|
|
logger.debug('Failed to parse Patroni version %s', version)
|
|
return None
|
|
|
|
@property
|
|
def lsn(self) -> Optional[int]:
|
|
"""Current LSN (receive/flush/replay)."""
|
|
return parse_int(self.data.get('xlog_location'))
|
|
|
|
|
|
class RemoteMember(Member):
|
|
"""Represents a remote member (typically a primary) for a standby cluster.
|
|
|
|
:cvar ALLOWED_KEYS: Controls access to relevant key names that could be in stored :attr:`~RemoteMember.data`.
|
|
"""
|
|
|
|
ALLOWED_KEYS: Tuple[str, ...] = (
|
|
'primary_slot_name',
|
|
'create_replica_methods',
|
|
'restore_command',
|
|
'archive_cleanup_command',
|
|
'recovery_min_apply_delay',
|
|
'no_replication_slot'
|
|
)
|
|
|
|
def __new__(cls, name: str, data: Dict[str, Any]) -> 'RemoteMember':
|
|
"""Factory method to construct instance from given *name* and *data*.
|
|
|
|
:param name: name of the remote member.
|
|
:param data: dictionary of member information, which can contain keys from :const:`~RemoteMember.ALLOWED_KEYS`
|
|
but also member connection information ``api_url`` and ``conn_kwargs``, and slot information.
|
|
|
|
:returns: constructed instance using supplied parameters.
|
|
"""
|
|
return super(RemoteMember, cls).__new__(cls, -1, name, None, data)
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
"""Dictionary style key lookup.
|
|
|
|
:param name: key to lookup.
|
|
|
|
:returns: value of *name* key in :attr:`~RemoteMember.data` if key *name* is in
|
|
:cvar:`~RemoteMember.ALLOWED_KEYS`, else ``None``.
|
|
"""
|
|
return self.data.get(name) if name in RemoteMember.ALLOWED_KEYS else None
|
|
|
|
|
|
class Leader(NamedTuple):
|
|
"""Immutable object (namedtuple) which represents leader key.
|
|
|
|
Consists of the following fields:
|
|
|
|
:ivar version: modification version of a leader key in a Configuration Store
|
|
:ivar session: either session id or just ttl in seconds
|
|
:ivar member: reference to a :class:`Member` object which represents current leader (see :attr:`Cluster.members`)
|
|
"""
|
|
|
|
version: _Version
|
|
session: _Session
|
|
member: Member
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""The leader "member" name."""
|
|
return self.member.name
|
|
|
|
def conn_kwargs(self, auth: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
"""Connection keyword arguments.
|
|
|
|
:param auth: an optional dictionary containing authentication information.
|
|
|
|
:returns: the result of the called :meth:`Member.conn_kwargs` method.
|
|
"""
|
|
return self.member.conn_kwargs(auth)
|
|
|
|
@property
|
|
def conn_url(self) -> Optional[str]:
|
|
"""Connection URL value of the :class:`Member` instance."""
|
|
return self.member.conn_url
|
|
|
|
@property
|
|
def data(self) -> Dict[str, Any]:
|
|
"""Data value of the :class:`Member` instance."""
|
|
return self.member.data
|
|
|
|
@property
|
|
def timeline(self) -> Optional[int]:
|
|
"""Timeline value of :attr:`~Member.data`."""
|
|
return self.data.get('timeline')
|
|
|
|
@property
|
|
def checkpoint_after_promote(self) -> Optional[bool]:
|
|
"""Determine whether a checkpoint has occurred for this leader after promotion.
|
|
|
|
:returns: ``True`` if the role is ``master`` or ``primary`` and ``checkpoint_after_promote`` is not set,
|
|
``False`` if not a ``master`` or ``primary`` or if the checkpoint hasn't occurred.
|
|
If the version of Patroni is older than 1.5.6, return ``None``.
|
|
|
|
:Example:
|
|
|
|
>>> Leader(1, '', Member.from_node(1, '', '', '{"version":"z"}')).checkpoint_after_promote
|
|
"""
|
|
version = self.member.patroni_version
|
|
# 1.5.6 is the last version which doesn't expose checkpoint_after_promote: false
|
|
if version and version > (1, 5, 6):
|
|
return self.data.get('role') in ('master', 'primary') and 'checkpoint_after_promote' not in self.data
|
|
return None
|
|
|
|
|
|
class Failover(NamedTuple):
|
|
"""Immutable object (namedtuple) representing configuration information required for failover/switchover capability.
|
|
|
|
:ivar version: version of the object.
|
|
:ivar leader: name of the leader. If value isn't empty we treat it as a switchover from the specified node.
|
|
:ivar candidate: the name of the member node to be considered as a failover candidate.
|
|
:ivar scheduled_at: in the case of a switchover the :class:`~datetime.datetime` object to perform the scheduled
|
|
switchover.
|
|
|
|
:Example:
|
|
|
|
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader"}'))
|
|
True
|
|
|
|
>>> 'Failover' in str(Failover.from_node(1, {"leader": "cluster_leader"}))
|
|
True
|
|
|
|
>>> 'Failover' in str(Failover.from_node(1, '{"leader": "cluster_leader", "member": "cluster_candidate"}'))
|
|
True
|
|
|
|
>>> Failover.from_node(1, 'null') is None
|
|
False
|
|
|
|
>>> n = '''{"leader": "cluster_leader", "member": "cluster_candidate",
|
|
... "scheduled_at": "2016-01-14T10:09:57.1394Z"}'''
|
|
|
|
>>> 'tzinfo=' in str(Failover.from_node(1, n))
|
|
True
|
|
|
|
>>> Failover.from_node(1, None) is None
|
|
False
|
|
|
|
>>> Failover.from_node(1, '{}') is None
|
|
False
|
|
|
|
>>> 'abc' in Failover.from_node(1, 'abc:def')
|
|
True
|
|
"""
|
|
|
|
version: _Version
|
|
leader: Optional[str]
|
|
candidate: Optional[str]
|
|
scheduled_at: Optional[datetime.datetime]
|
|
|
|
@staticmethod
|
|
def from_node(version: _Version, value: Union[str, Dict[str, str]]) -> 'Failover':
|
|
"""Factory method to parse *value* as failover configuration.
|
|
|
|
:param version: version number for the object.
|
|
:param value: JSON serialized data or a dictionary of configuration.
|
|
Can also be a colon ``:`` delimited list of leader, followed by candidate name (legacy format).
|
|
If ``scheduled_at`` key is defined the value will be parsed by :func:`dateutil.parser.parse`.
|
|
|
|
:returns: constructed :class:`Failover` information object
|
|
"""
|
|
if isinstance(value, dict):
|
|
data: Dict[str, Any] = value
|
|
elif value:
|
|
try:
|
|
data = json.loads(value)
|
|
assert isinstance(data, dict)
|
|
except AssertionError:
|
|
data = {}
|
|
except ValueError:
|
|
t = [a.strip() for a in value.split(':')]
|
|
leader = t[0]
|
|
candidate = t[1] if len(t) > 1 else None
|
|
return Failover(version, leader, candidate, None)
|
|
else:
|
|
data = {}
|
|
|
|
if data.get('scheduled_at'):
|
|
data['scheduled_at'] = dateutil.parser.parse(data['scheduled_at'])
|
|
|
|
return Failover(version, data.get('leader'), data.get('member'), data.get('scheduled_at'))
|
|
|
|
def __len__(self) -> int:
|
|
"""Implement ``len`` function capability.
|
|
|
|
.. note::
|
|
This magic method aids in the evaluation of "emptiness" of a :class:`Failover` instance. For example:
|
|
|
|
>>> failover = Failover.from_node(1, None)
|
|
>>> len(failover)
|
|
0
|
|
>>> assert bool(failover) is False
|
|
|
|
>>> failover = Failover.from_node(1, {"leader": "cluster_leader"})
|
|
>>> len(failover)
|
|
1
|
|
>>> assert bool(failover) is True
|
|
|
|
This makes it easier to write ``if cluster.failover`` rather than the longer statement.
|
|
|
|
"""
|
|
return int(bool(self.leader)) + int(bool(self.candidate))
|
|
|
|
|
|
class ClusterConfig(NamedTuple):
|
|
"""Immutable object (namedtuple) which represents cluster configuration.
|
|
|
|
:ivar version: version number for the object.
|
|
:ivar data: dictionary of configuration information.
|
|
:ivar modify_version: modified version number.
|
|
"""
|
|
|
|
version: _Version
|
|
data: Dict[str, Any]
|
|
modify_version: _Version
|
|
|
|
@staticmethod
|
|
def from_node(version: _Version, value: str, modify_version: Optional[_Version] = None) -> 'ClusterConfig':
|
|
"""Factory method to parse *value* as configuration information.
|
|
|
|
:param version: version number for object.
|
|
:param value: raw JSON serialized data, if not parsable replaced with an empty dictionary.
|
|
:param modify_version: optional modify version number, use *version* if not provided.
|
|
|
|
:returns: constructed :class:`ClusterConfig` instance.
|
|
|
|
:Example:
|
|
|
|
>>> ClusterConfig.from_node(1, '{') is None
|
|
False
|
|
"""
|
|
try:
|
|
data = json.loads(value)
|
|
assert isinstance(data, dict)
|
|
except (AssertionError, TypeError, ValueError):
|
|
data: Dict[str, Any] = {}
|
|
modify_version = 0
|
|
return ClusterConfig(version, data, version if modify_version is None else modify_version)
|
|
|
|
|
|
class SyncState(NamedTuple):
|
|
"""Immutable object (namedtuple) which represents last observed synchronous replication state.
|
|
|
|
:ivar version: modification version of a synchronization key in a Configuration Store.
|
|
:ivar leader: reference to member that was leader.
|
|
:ivar sync_standby: synchronous standby list (comma delimited) which are last synchronized to leader.
|
|
:ivar quorum: if the node from :attr:`~SyncState.sync_standby` list is doing a leader race it should
|
|
see at least :attr:`~SyncState.quorum` other nodes from the
|
|
:attr:`~SyncState.sync_standby` + :attr:`~SyncState.leader` list.
|
|
"""
|
|
|
|
version: Optional[_Version]
|
|
leader: Optional[str]
|
|
sync_standby: Optional[str]
|
|
quorum: int
|
|
|
|
@staticmethod
|
|
def from_node(version: Optional[_Version], value: Union[str, Dict[str, Any], None]) -> 'SyncState':
|
|
"""Factory method to parse *value* as synchronisation state information.
|
|
|
|
:param version: optional *version* number for the object.
|
|
:param value: (optionally JSON serialised) synchronisation state information
|
|
|
|
:returns: constructed :class:`SyncState` object.
|
|
|
|
:Example:
|
|
|
|
>>> SyncState.from_node(1, None).leader is None
|
|
True
|
|
|
|
>>> SyncState.from_node(1, '{}').leader is None
|
|
True
|
|
|
|
>>> SyncState.from_node(1, '{').leader is None
|
|
True
|
|
|
|
>>> SyncState.from_node(1, '[]').leader is None
|
|
True
|
|
|
|
>>> SyncState.from_node(1, '{"leader": "leader"}').leader == "leader"
|
|
True
|
|
|
|
>>> SyncState.from_node(1, {"leader": "leader"}).leader == "leader"
|
|
True
|
|
"""
|
|
try:
|
|
if value and isinstance(value, str):
|
|
value = json.loads(value)
|
|
assert isinstance(value, dict)
|
|
leader = value.get('leader')
|
|
quorum = value.get('quorum')
|
|
return SyncState(version, leader, value.get('sync_standby'), int(quorum) if leader and quorum else 0)
|
|
except (AssertionError, TypeError, ValueError):
|
|
return SyncState.empty(version)
|
|
|
|
@staticmethod
|
|
def empty(version: Optional[_Version] = None) -> 'SyncState':
|
|
"""Construct an empty :class:`SyncState` instance.
|
|
|
|
:param version: optional version number.
|
|
|
|
:returns: empty synchronisation state object.
|
|
"""
|
|
return SyncState(version, None, None, 0)
|
|
|
|
@property
|
|
def is_empty(self) -> bool:
|
|
"""``True`` if ``/sync`` key is not valid (doesn't have a leader)."""
|
|
return not self.leader
|
|
|
|
@staticmethod
|
|
def _str_to_list(value: str) -> List[str]:
|
|
"""Splits a string by comma and returns list of strings.
|
|
|
|
:param value: a comma separated string.
|
|
|
|
:returns: list of non-empty strings after splitting an input value by comma.
|
|
"""
|
|
return list(filter(lambda a: a, [s.strip() for s in value.split(',')]))
|
|
|
|
@property
|
|
def voters(self) -> List[str]:
|
|
""":attr:`~SyncState.sync_standby` as list or an empty list if undefined or object considered ``empty``."""
|
|
return self._str_to_list(self.sync_standby) if not self.is_empty and self.sync_standby else []
|
|
|
|
@property
|
|
def members(self) -> List[str]:
|
|
""":attr:`~SyncState.sync_standby` and :attr:`~SyncState.leader` as list
|
|
or an empty list if object considered ``empty``.
|
|
"""
|
|
return [] if not self.leader else [self.leader] + self.voters
|
|
|
|
def matches(self, name: Optional[str], check_leader: bool = False) -> bool:
|
|
"""Checks if node is presented in the /sync state.
|
|
|
|
Since PostgreSQL does case-insensitive checks for synchronous_standby_name we do it also.
|
|
|
|
:param name: name of the node.
|
|
:param check_leader: by default the *name* is searched for only in members, a value of ``True`` will include the
|
|
leader to list.
|
|
|
|
:returns: ``True`` if the ``/sync`` key not :func:`is_empty` and the given *name* is among those presented in
|
|
the sync state.
|
|
|
|
:Example:
|
|
>>> s = SyncState(1, 'foo', 'bar,zoo', 0)
|
|
|
|
>>> s.matches('foo')
|
|
False
|
|
|
|
>>> s.matches('fOo', True)
|
|
True
|
|
|
|
>>> s.matches('Bar')
|
|
True
|
|
|
|
>>> s.matches('zoO')
|
|
True
|
|
|
|
>>> s.matches('baz')
|
|
False
|
|
|
|
>>> s.matches(None)
|
|
False
|
|
|
|
>>> SyncState.empty(1).matches('foo')
|
|
False
|
|
"""
|
|
ret = False
|
|
if name and not self.is_empty:
|
|
search_str = (self.sync_standby or '') + (',' + (self.leader or '') if check_leader else '')
|
|
ret = name.lower() in self._str_to_list(search_str.lower())
|
|
return ret
|
|
|
|
def leader_matches(self, name: Optional[str]) -> bool:
|
|
"""Compare the given *name* to stored leader value.
|
|
|
|
:returns: ``True`` if *name* is matching the :attr:`~SyncState.leader` value.
|
|
"""
|
|
return bool(name and not self.is_empty and name.lower() == (self.leader or '').lower())
|
|
|
|
|
|
_HistoryTuple = Union[Tuple[int, int, str], Tuple[int, int, str, str], Tuple[int, int, str, str, str]]
|
|
|
|
|
|
class TimelineHistory(NamedTuple):
|
|
"""Object representing timeline history file.
|
|
|
|
.. note::
|
|
The content held in *lines* deserialized from *value* are lines parsed from PostgreSQL timeline history files,
|
|
consisting of the timeline number, the LSN where the timeline split and any other string held in the file.
|
|
The files are parsed by :func:`~patroni.postgresql.misc.parse_history`.
|
|
|
|
:ivar version: version number of the file.
|
|
:ivar value: raw JSON serialised data consisting of parsed lines from history files.
|
|
:ivar lines: ``List`` of ``Tuple`` parsed lines from history files.
|
|
"""
|
|
|
|
version: _Version
|
|
value: Any
|
|
lines: List[_HistoryTuple]
|
|
|
|
@staticmethod
|
|
def from_node(version: _Version, value: str) -> 'TimelineHistory':
|
|
"""Parse the given JSON serialized string as a list of timeline history lines.
|
|
|
|
:param version: version number
|
|
:param value: JSON serialized string, consisting of parsed lines of PostgreSQL timeline history files,
|
|
see :class:`TimelineHistory`.
|
|
|
|
:returns: composed timeline history object using parsed lines.
|
|
|
|
:Example:
|
|
|
|
If the passed *value* argument is not parsed an empty list of lines is returned:
|
|
|
|
>>> h = TimelineHistory.from_node(1, 2)
|
|
|
|
>>> h.lines
|
|
[]
|
|
"""
|
|
try:
|
|
lines = json.loads(value)
|
|
assert isinstance(lines, list)
|
|
except (AssertionError, TypeError, ValueError):
|
|
lines: List[_HistoryTuple] = []
|
|
return TimelineHistory(version, value, lines)
|
|
|
|
|
|
class Status(NamedTuple):
|
|
"""Immutable object (namedtuple) which represents `/status` key.
|
|
|
|
Consists of the following fields:
|
|
|
|
:ivar last_lsn: :class:`int` object containing position of last known leader LSN.
|
|
:ivar slots: state of permanent replication slots on the primary in the format: ``{"slot_name": int}``.
|
|
:ivar retain_slots: list physical replication slots for members that exist in the cluster.
|
|
"""
|
|
last_lsn: int
|
|
slots: Optional[Dict[str, int]]
|
|
retain_slots: List[str]
|
|
|
|
@staticmethod
|
|
def empty() -> 'Status':
|
|
"""Construct an empty :class:`Status` instance.
|
|
|
|
:returns: empty :class:`Status` object.
|
|
"""
|
|
return Status(0, None, [])
|
|
|
|
def is_empty(self):
|
|
"""Validate definition of all attributes of this :class:`Status` instance.
|
|
|
|
:returns: ``True`` if all attributes of the current :class:`Status` are unpopulated.
|
|
"""
|
|
return self.last_lsn == 0 and self.slots is None and not self.retain_slots
|
|
|
|
@staticmethod
|
|
def from_node(value: Union[str, Dict[str, Any], None]) -> 'Status':
|
|
"""Factory method to parse *value* as :class:`Status` object.
|
|
|
|
:param value: JSON serialized string or :class:`dict` object.
|
|
|
|
:returns: constructed :class:`Status` object.
|
|
"""
|
|
try:
|
|
if isinstance(value, str):
|
|
value = json.loads(value)
|
|
except Exception:
|
|
return Status.empty()
|
|
|
|
if isinstance(value, int): # legacy
|
|
return Status(value, None, [])
|
|
|
|
if not isinstance(value, dict):
|
|
return Status.empty()
|
|
|
|
try:
|
|
last_lsn = int(value.get('optime', ''))
|
|
except Exception:
|
|
last_lsn = 0
|
|
|
|
slots: Union[str, Dict[str, int], None] = value.get('slots')
|
|
if isinstance(slots, str):
|
|
try:
|
|
slots = json.loads(slots)
|
|
except Exception:
|
|
slots = None
|
|
if not isinstance(slots, dict):
|
|
slots = None
|
|
|
|
retain_slots: Union[str, List[str], None] = value.get('retain_slots')
|
|
if isinstance(retain_slots, str):
|
|
try:
|
|
retain_slots = json.loads(retain_slots)
|
|
except Exception:
|
|
retain_slots = []
|
|
if not isinstance(retain_slots, list):
|
|
retain_slots = []
|
|
|
|
return Status(last_lsn, slots, retain_slots)
|
|
|
|
|
|
class Cluster(NamedTuple('Cluster',
|
|
[('initialize', Optional[str]),
|
|
('config', Optional[ClusterConfig]),
|
|
('leader', Optional[Leader]),
|
|
('status', Status),
|
|
('members', List[Member]),
|
|
('failover', Optional[Failover]),
|
|
('sync', SyncState),
|
|
('history', Optional[TimelineHistory]),
|
|
('failsafe', Optional[Dict[str, str]]),
|
|
('workers', Dict[int, 'Cluster'])])):
|
|
"""Immutable object (namedtuple) which represents PostgreSQL or MPP cluster.
|
|
|
|
.. note::
|
|
We are using an old-style attribute declaration here because otherwise it is not possible to override `__new__`
|
|
method. Without it the *workers* by default gets always the same :class:`dict` object that could be mutated.
|
|
|
|
Consists of the following fields:
|
|
|
|
:ivar initialize: shows whether this cluster has initialization key stored in DC or not.
|
|
:ivar config: global dynamic configuration, reference to `ClusterConfig` object.
|
|
:ivar leader: :class:`Leader` object which represents current leader of the cluster.
|
|
:ivar status: :class:`Status` object which represents the `/status` key.
|
|
:ivar members: list of:class:` Member` objects, all PostgreSQL cluster members including leader
|
|
:ivar failover: reference to :class:`Failover` object.
|
|
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
|
|
:ivar history: reference to `TimelineHistory` object.
|
|
:ivar failsafe: failsafe topology. Node is allowed to become the leader only if its name is found in this list.
|
|
:ivar workers: dictionary of workers of the MPP cluster, optional. Each key representing the group and the
|
|
corresponding value is a :class:`Cluster` instance.
|
|
"""
|
|
|
|
def __new__(cls, *args: Any, **kwargs: Any):
|
|
"""Make workers argument optional and set it to an empty dict object."""
|
|
if len(args) < len(cls._fields) and 'workers' not in kwargs:
|
|
kwargs['workers'] = {}
|
|
return super(Cluster, cls).__new__(cls, *args, **kwargs)
|
|
|
|
@property
|
|
def slots(self) -> Dict[str, int]:
|
|
"""State of permanent replication slots on the primary in the format: ``{"slot_name": int}``.
|
|
|
|
.. note::
|
|
We are trying to be foolproof here and for values that can't be parsed to :class:`int` will return ``0``.
|
|
"""
|
|
return {k: parse_int(v) or 0 for k, v in (self.status.slots or {}).items()}
|
|
|
|
@staticmethod
|
|
def empty() -> 'Cluster':
|
|
"""Produce an empty :class:`Cluster` instance."""
|
|
return Cluster(None, None, None, Status.empty(), [], None, SyncState.empty(), None, None, {})
|
|
|
|
def is_empty(self):
|
|
"""Validate definition of all attributes of this :class:`Cluster` instance.
|
|
|
|
:returns: ``True`` if all attributes of the current :class:`Cluster` are unpopulated.
|
|
"""
|
|
return all((self.initialize is None, self.config is None, self.leader is None, self.status.is_empty(),
|
|
self.members == [], self.failover is None, self.sync.version is None,
|
|
self.history is None, self.failsafe is None, self.workers == {}))
|
|
|
|
def __len__(self) -> int:
|
|
"""Implement ``len`` function capability.
|
|
|
|
.. note::
|
|
This magic method aids in the evaluation of "emptiness" of a ``Cluster`` instance. For example:
|
|
|
|
>>> cluster = Cluster.empty()
|
|
>>> len(cluster)
|
|
0
|
|
|
|
>>> assert bool(cluster) is False
|
|
|
|
>>> status = Status(0, None, [])
|
|
>>> cluster = Cluster(None, None, None, status, [1, 2, 3], None, SyncState.empty(), None, None, {})
|
|
>>> len(cluster)
|
|
1
|
|
|
|
>>> assert bool(cluster) is True
|
|
|
|
This makes it easier to write ``if cluster`` rather than the longer statement.
|
|
|
|
"""
|
|
return int(not self.is_empty())
|
|
|
|
@property
|
|
def leader_name(self) -> Optional[str]:
|
|
"""The name of the leader if defined otherwise ``None``."""
|
|
return self.leader and self.leader.name
|
|
|
|
def is_unlocked(self) -> bool:
|
|
"""Check if the cluster does not have the leader.
|
|
|
|
:returns: ``True`` if a leader name is not defined.
|
|
"""
|
|
return not self.leader_name
|
|
|
|
def has_member(self, member_name: str) -> bool:
|
|
"""Check if the given member name is present in the cluster.
|
|
|
|
:param member_name: name to look up in the :attr:`~Cluster.members`.
|
|
|
|
:returns: ``True`` if the member name is found.
|
|
"""
|
|
return any(m for m in self.members if m.name == member_name)
|
|
|
|
def get_member(self, member_name: str, fallback_to_leader: bool = True) -> Union[Member, Leader, None]:
|
|
"""Get :class:`Member` object by name or the :class:`Leader`.
|
|
|
|
:param member_name: name of the member to retrieve.
|
|
:param fallback_to_leader: if ``True`` return the :class:`Leader` instead if the member cannot be found.
|
|
|
|
:returns: the :class:`Member` if found or :class:`Leader` object.
|
|
"""
|
|
return next((m for m in self.members if m.name == member_name),
|
|
self.leader if fallback_to_leader else None)
|
|
|
|
def get_clone_member(self, exclude_name: str) -> Union[Member, Leader, None]:
|
|
"""Get member or leader object to use as clone source.
|
|
|
|
:param exclude_name: name of a member name to exclude.
|
|
|
|
:returns: a randomly selected candidate member from available running members that are configured to as viable
|
|
sources for cloning (has tag ``clonefrom`` in configuration). If no member is appropriate the current
|
|
leader is used.
|
|
"""
|
|
exclude = [exclude_name] + ([self.leader.name] if self.leader else [])
|
|
candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude]
|
|
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
|
|
|
|
@staticmethod
|
|
def is_physical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
|
|
"""Check whether provided configuration is for permanent physical replication slot.
|
|
|
|
:param value: configuration of the permanent replication slot.
|
|
|
|
:returns: ``True`` if *value* is a physical replication slot, otherwise ``False``.
|
|
"""
|
|
return not value \
|
|
or (isinstance(value, dict) and not Cluster.is_logical_slot(value)
|
|
and value.get('type', 'physical') == 'physical')
|
|
|
|
@staticmethod
|
|
def is_logical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
|
|
"""Check whether provided configuration is for permanent logical replication slot.
|
|
|
|
:param value: configuration of the permanent replication slot.
|
|
|
|
:returns: ``True`` if *value* is a logical replication slot, otherwise ``False``.
|
|
"""
|
|
return isinstance(value, dict) \
|
|
and value.get('type', 'logical') == 'logical' \
|
|
and bool(value.get('database') and value.get('plugin'))
|
|
|
|
@property
|
|
def __permanent_slots(self) -> Dict[str, Union[Dict[str, Any], Any]]:
|
|
"""Dictionary of permanent replication slots with their known LSN."""
|
|
ret: Dict[str, Union[Dict[str, Any], Any]] = global_config.permanent_slots
|
|
|
|
members: Dict[str, int] = {slot_name_from_member_name(m.name): m.lsn or 0
|
|
for m in self.members if m.replicatefrom}
|
|
slots: Dict[str, int] = self.slots
|
|
for name, value in list(ret.items()):
|
|
if not value:
|
|
value = ret[name] = {}
|
|
if isinstance(value, dict):
|
|
# For permanent physical slots we want to get MAX LSN from the `Cluster.slots` and from the
|
|
# member that does cascading replication with the matching name (see `replicatefrom` tag).
|
|
# It is necessary because we may have the permanent replication slot on the primary for this node.
|
|
lsn = max(members.get(name, 0) if self.is_physical_slot(value) else 0, slots.get(name, 0))
|
|
if lsn:
|
|
value['lsn'] = lsn
|
|
else:
|
|
# Don't let anyone set 'lsn' in the global configuration :)
|
|
value.pop('lsn', None)
|
|
return ret
|
|
|
|
@property
|
|
def permanent_physical_slots(self) -> Dict[str, Any]:
|
|
"""Dictionary of permanent ``physical`` replication slots."""
|
|
return {name: value for name, value in self.__permanent_slots.items() if self.is_physical_slot(value)}
|
|
|
|
@property
|
|
def __permanent_logical_slots(self) -> Dict[str, Any]:
|
|
"""Dictionary of permanent ``logical`` replication slots."""
|
|
return {name: value for name, value in self.__permanent_slots.items() if self.is_logical_slot(value)}
|
|
|
|
def get_replication_slots(self, postgresql: 'Postgresql', member: Tags, *,
|
|
role: Optional[str] = None, show_error: bool = False) -> Dict[str, Dict[str, Any]]:
|
|
"""Lookup configured slot names in the DCS, report issues found and merge with permanent slots.
|
|
|
|
Will log an error if:
|
|
|
|
* Any logical slots are disabled, due to version compatibility, and *show_error* is ``True``.
|
|
|
|
:param postgresql: reference to :class:`Postgresql` object.
|
|
:param member: reference to an object implementing :class:`Tags` interface.
|
|
:param role: role of the node, if not set will be taken from *postgresql*.
|
|
:param show_error: if ``True`` report error if any disabled logical slots or conflicting slot names are found.
|
|
|
|
:returns: final dictionary of slot names, after merging with permanent slots and performing sanity checks.
|
|
"""
|
|
name = member.name if isinstance(member, Member) else postgresql.name
|
|
role = role or postgresql.role
|
|
|
|
slots: Dict[str, Dict[str, Any]] = self._get_members_slots(name, role,
|
|
member.nofailover, postgresql.can_advance_slots)
|
|
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
|
|
|
|
disabled_permanent_logical_slots: List[str] = self._merge_permanent_slots(
|
|
slots, permanent_slots, name, postgresql.can_advance_slots)
|
|
|
|
if disabled_permanent_logical_slots and show_error:
|
|
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
|
|
"Following slots will not be created: %s.", disabled_permanent_logical_slots)
|
|
|
|
return slots
|
|
|
|
def _merge_permanent_slots(self, slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], name: str,
|
|
can_advance_slots: bool) -> List[str]:
|
|
"""Merge replication *slots* for members with *permanent_slots*.
|
|
|
|
Perform validation of configured permanent slot name, skipping invalid names.
|
|
|
|
Will update *slots* in-line based on ``type`` of slot, ``physical`` or ``logical``, and name of node.
|
|
Type is assumed to be ``physical`` if there are no attributes stored as the slot value.
|
|
|
|
:param slots: Slot names with existing attributes if known.
|
|
:param name: name of this node.
|
|
:param permanent_slots: dictionary containing slot name key and slot information values.
|
|
:param can_advance_slots: ``True`` if ``pg_replication_slot_advance()`` function is available,
|
|
``False`` otherwise.
|
|
|
|
:returns: List of disabled permanent, logical slot names, if postgresql version < 11.
|
|
"""
|
|
disabled_permanent_logical_slots: List[str] = []
|
|
|
|
for slot_name, value in permanent_slots.items():
|
|
if not slot_name_re.match(slot_name):
|
|
logger.error("Invalid permanent replication slot name '%s'", slot_name)
|
|
logger.error("Slot name may only contain lower case letters, numbers, and the underscore chars")
|
|
continue
|
|
|
|
value = deepcopy(value) if value else {'type': 'physical'}
|
|
if isinstance(value, dict):
|
|
if 'type' not in value:
|
|
value['type'] = 'logical' if value.get('database') and value.get('plugin') else 'physical'
|
|
|
|
if value['type'] == 'physical':
|
|
# Don't try to create permanent physical replication slot for yourself
|
|
if slot_name not in slots and slot_name != slot_name_from_member_name(name):
|
|
slots[slot_name] = value
|
|
continue
|
|
|
|
if self.is_logical_slot(value):
|
|
if not can_advance_slots:
|
|
disabled_permanent_logical_slots.append(slot_name)
|
|
elif slot_name in slots:
|
|
logger.error("Permanent logical replication slot {'%s': %s} is conflicting with"
|
|
" physical replication slot for cluster member", slot_name, value)
|
|
else:
|
|
slots[slot_name] = value
|
|
continue
|
|
|
|
logger.error("Bad value for slot '%s' in permanent_slots: %s", slot_name, permanent_slots[slot_name])
|
|
return disabled_permanent_logical_slots
|
|
|
|
def _get_permanent_slots(self, postgresql: 'Postgresql', tags: Tags, role: str) -> Dict[str, Any]:
|
|
"""Get configured permanent replication slots.
|
|
|
|
.. note::
|
|
Permanent replication slots are only considered if ``use_slots`` configuration is enabled.
|
|
A node that is not supposed to become a leader (*nofailover*) will not have permanent replication slots.
|
|
Also node with disabled streaming (*nostream*) and its cascading followers must not have permanent
|
|
logical slots due to lack of feedback from node to primary, which makes them unsafe to use.
|
|
|
|
In a standby cluster we only support physical replication slots.
|
|
|
|
The returned dictionary for a non-standby cluster always contains permanent logical replication slots in
|
|
order to show a warning if they are not supported by PostgreSQL before v11.
|
|
|
|
:param postgresql: reference to :class:`Postgresql` object.
|
|
:param tags: reference to an object implementing :class:`Tags` interface.
|
|
:param role: role of the node -- ``primary``, ``standby_leader`` or ``replica``.
|
|
|
|
:returns: dictionary of permanent slot names mapped to attributes.
|
|
"""
|
|
if not global_config.use_slots or tags.nofailover:
|
|
return {}
|
|
|
|
if global_config.is_standby_cluster or self.get_slot_name_on_primary(postgresql.name, tags) is None:
|
|
return self.permanent_physical_slots if postgresql.can_advance_slots or role == 'standby_leader' else {}
|
|
|
|
return self.__permanent_slots if postgresql.can_advance_slots or role == 'primary' \
|
|
else self.__permanent_logical_slots
|
|
|
|
def _get_members_slots(self, name: str, role: str, nofailover: bool,
|
|
can_advance_slots: bool) -> Dict[str, Dict[str, Any]]:
|
|
"""Get physical replication slots configuration for a given member.
|
|
|
|
There are following situations possible:
|
|
|
|
* If the ``nostream`` tag is set on the member - we should not have the replication slot for it
|
|
on the current primary or any other member even if ``replicatefrom`` is set, because
|
|
``nostream`` disables WAL streaming.
|
|
|
|
* PostgreSQL is 11 and newer and configuration allows retention of member replication slots. In this case
|
|
we want to have replication slots for every member except the case when we have ``nofailover`` tag set.
|
|
|
|
* PostgreSQL is older than 11 or configuration doesn't allow member slots retention. In this case we want:
|
|
|
|
* On primary have replication slots for all members that don't have ``replicatefrom`` tag pointing
|
|
to the existing member.
|
|
|
|
* On replica node have replication slots only for members which ``replicatefrom`` tag pointing to us.
|
|
|
|
Will log an error if:
|
|
|
|
* Conflicting slot names between members are found
|
|
|
|
:param name: name of this node.
|
|
:param role: role of this node, ``primary``, ``standby_leader``, or ``replica``.
|
|
:param nofailover: ``True`` if this node is tagged to not be a failover candidate, ``False`` otherwise.
|
|
:param can_advance_slots: ``True`` if ``pg_replication_slot_advance()`` function is available,
|
|
``False`` otherwise.
|
|
|
|
:returns: dictionary of physical replication slots that should exist on a given node.
|
|
"""
|
|
if not global_config.use_slots:
|
|
return {}
|
|
|
|
# we always want to exclude the member with our name from the list,
|
|
# also exlude members with disabled WAL streaming
|
|
members = filter(lambda m: m.name != name and not m.nostream, self.members)
|
|
|
|
def leader_filter(member: Member) -> bool:
|
|
"""Check whether provided *member* should replicate from the current node when it is running as a leader.
|
|
|
|
:param member: a :class:`Member` object.
|
|
|
|
:returns: ``True`` if provided member should replicate from the current node, ``False`` otherwise.
|
|
"""
|
|
return member.replicatefrom is None or\
|
|
member.replicatefrom == name or\
|
|
not self.has_member(member.replicatefrom)
|
|
|
|
def replica_filter(member: Member) -> bool:
|
|
"""Check whether provided *member* should replicate from the current node when it is running as a replica.
|
|
|
|
..note::
|
|
We only consider members with ``replicatefrom`` tag that matches our name and always exclude the leader.
|
|
|
|
:param member: a :class:`Member` object.
|
|
|
|
:returns: ``True`` if provided member should replicate from the current node, ``False`` otherwise.
|
|
"""
|
|
return member.replicatefrom == name and member.name != self.leader_name
|
|
|
|
# In case when retention of replication slots is possible the `expected_active` function
|
|
# will be used to figure out whether the replication slot is expected to be active.
|
|
# Otherwise it will be used to find replication slots that should exist on a current node.
|
|
expected_active = leader_filter if role in ('primary', 'standby_leader') else replica_filter
|
|
|
|
if can_advance_slots and global_config.member_slots_ttl > 0:
|
|
# if the node does only cascading and can't become the leader, we
|
|
# want only to have slots for members that could connect to it.
|
|
members = [m for m in members if not nofailover or m.replicatefrom == name]
|
|
else:
|
|
members = [m for m in members if expected_active(m)]
|
|
|
|
slots: Dict[str, int] = self.slots
|
|
ret: Dict[str, Dict[str, Any]] = {}
|
|
for member in members:
|
|
slot_name = slot_name_from_member_name(member.name)
|
|
lsn = slots.get(slot_name, 0)
|
|
if member.replicatefrom:
|
|
# `/status` key is maintained by the leader, but `member` may be connected to some other node.
|
|
# In that case, the slot in the leader is inactive and doesn't advance, so we use the LSN
|
|
# reported by the member to advance replication slot LSN.
|
|
# `max` is only a fallback so we take the LSN from the slot when there is no feedback from the member.
|
|
lsn = max(member.lsn or 0, lsn)
|
|
ret[slot_name] = {'type': 'physical', 'lsn': lsn, 'expected_active': expected_active(member)}
|
|
slot_name = slot_name_from_member_name(name)
|
|
ret.update({slot: {'type': 'physical'} for slot in self.status.retain_slots
|
|
if not nofailover and slot not in ret and slot != slot_name})
|
|
|
|
if len(ret) < len(members):
|
|
# Find which names are conflicting for a nicer error message
|
|
slot_conflicts: Dict[str, List[str]] = defaultdict(list)
|
|
for member in members:
|
|
slot_conflicts[slot_name_from_member_name(member.name)].append(member.name)
|
|
logger.error("Following cluster members share a replication slot name: %s",
|
|
"; ".join(f"{', '.join(v)} map to {k}"
|
|
for k, v in slot_conflicts.items() if len(v) > 1))
|
|
return ret
|
|
|
|
def has_permanent_slots(self, postgresql: 'Postgresql', member: Tags) -> bool:
|
|
"""Check if our node has permanent replication slots configured.
|
|
|
|
:param postgresql: reference to :class:`Postgresql` object.
|
|
:param member: reference to an object implementing :class:`Tags` interface for
|
|
the node that we are checking permanent logical replication slots for.
|
|
:returns: ``True`` if there are permanent replication slots configured, otherwise ``False``.
|
|
"""
|
|
role = 'replica'
|
|
members_slots: Dict[str, Dict[str, str]] = self._get_members_slots(postgresql.name, role,
|
|
member.nofailover,
|
|
postgresql.can_advance_slots)
|
|
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
|
|
slots = deepcopy(members_slots)
|
|
self._merge_permanent_slots(slots, permanent_slots, postgresql.name, postgresql.can_advance_slots)
|
|
return len(slots) > len(members_slots) or any(self.is_physical_slot(v) for v in permanent_slots.values())
|
|
|
|
def maybe_filter_permanent_slots(self, postgresql: 'Postgresql', slots: Dict[str, int]) -> Dict[str, int]:
|
|
"""Filter out all non-permanent slots from provided *slots* dict.
|
|
|
|
.. note::
|
|
In case if retention of replication slots for members is enabled we will not do
|
|
any filtering, because we need to publish LSN values for members replication slots,
|
|
so that other nodes can use them to advance LSN, like they do it for permanent slots.
|
|
|
|
:param postgresql: reference to :class:`Postgresql` object.
|
|
:param slots: slot names with LSN values.
|
|
:returns: a :class:`dict` object that contains only slots that are known to be permanent.
|
|
"""
|
|
if global_config.member_slots_ttl > 0:
|
|
return slots
|
|
|
|
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, RemoteMember('', {}), 'replica')
|
|
members_slots = {slot_name_from_member_name(m.name) for m in self.members}
|
|
|
|
return {name: value for name, value in slots.items() if name in permanent_slots
|
|
and (self.is_physical_slot(permanent_slots[name])
|
|
or self.is_logical_slot(permanent_slots[name]) and name not in members_slots)}
|
|
|
|
def _has_permanent_logical_slots(self, postgresql: 'Postgresql', member: Tags) -> bool:
|
|
"""Check if the given member node has permanent ``logical`` replication slots configured.
|
|
|
|
:param postgresql: reference to a :class:`Postgresql` object.
|
|
:param member: reference to an object implementing :class:`Tags` interface for
|
|
the node that we are checking permanent logical replication slots for.
|
|
|
|
:returns: ``True`` if any detected replications slots are ``logical``, otherwise ``False``.
|
|
"""
|
|
slots = self.get_replication_slots(postgresql, member, role='replica').values()
|
|
return any(v for v in slots if v.get("type") == "logical")
|
|
|
|
def should_enforce_hot_standby_feedback(self, postgresql: 'Postgresql', member: Tags) -> bool:
|
|
"""Determine whether ``hot_standby_feedback`` should be enabled for the given member.
|
|
|
|
The ``hot_standby_feedback`` must be enabled if the current replica has ``logical`` slots,
|
|
or it is working as a cascading replica for the other node that has ``logical`` slots.
|
|
|
|
:param postgresql: reference to a :class:`Postgresql` object.
|
|
:param member: reference to an object implementing :class:`Tags` interface for
|
|
the node that we are checking permanent logical replication slots for.
|
|
|
|
:returns: ``True`` if this node or any member replicating from this node has
|
|
permanent logical slots, otherwise ``False``.
|
|
"""
|
|
if self._has_permanent_logical_slots(postgresql, member):
|
|
return True
|
|
|
|
if global_config.use_slots:
|
|
name = member.name if isinstance(member, Member) else postgresql.name
|
|
|
|
if not self.get_slot_name_on_primary(name, member):
|
|
return False
|
|
|
|
members = [m for m in self.members if m.replicatefrom == name and m.name != self.leader_name]
|
|
return any(self.should_enforce_hot_standby_feedback(postgresql, m) for m in members)
|
|
return False
|
|
|
|
def get_slot_name_on_primary(self, name: str, tags: Tags) -> Optional[str]:
|
|
"""Get the name of physical replication slot for this node on the primary.
|
|
|
|
.. note::
|
|
P <-- I <-- L
|
|
|
|
In case of cascading replication we have to check not our physical slot, but slot of the replica that
|
|
connects us to the primary.
|
|
|
|
:param name: name of the member node to check.
|
|
:param tags: reference to an object implementing :class:`Tags` interface.
|
|
|
|
:returns: the slot name on the primary that is in use for physical replication on this node.
|
|
"""
|
|
seen_nodes: Set[str] = set()
|
|
while True:
|
|
seen_nodes.add(name)
|
|
if tags.nostream:
|
|
return None
|
|
replicatefrom = self.get_member(tags.replicatefrom, False) \
|
|
if tags.replicatefrom and tags.replicatefrom != name else None
|
|
if not isinstance(replicatefrom, Member):
|
|
return slot_name_from_member_name(name)
|
|
if replicatefrom.name in seen_nodes:
|
|
return None
|
|
name, tags = replicatefrom.name, replicatefrom
|
|
|
|
@property
|
|
def timeline(self) -> int:
|
|
"""Get the cluster history index from the :attr:`~Cluster.history`.
|
|
|
|
:returns: If the recorded history is empty assume timeline is ``1``, if it is not defined or the stored history
|
|
is not formatted as expected ``0`` is returned and an error will be logged.
|
|
Otherwise, the last number stored incremented by 1 is returned.
|
|
|
|
:Example:
|
|
|
|
No history provided:
|
|
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, 0, None, {}).timeline
|
|
0
|
|
|
|
Empty history assume timeline is ``1``:
|
|
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[]'), None, {}).timeline
|
|
1
|
|
|
|
Invalid history format, a string of ``a``, returns ``0``:
|
|
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), None, {}).timeline
|
|
0
|
|
|
|
History as a list of strings:
|
|
>>> history = TimelineHistory.from_node(1, '[["3", "2", "1"]]')
|
|
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, history, None, {}).timeline
|
|
4
|
|
"""
|
|
if self.history:
|
|
if self.history.lines:
|
|
try:
|
|
return int(self.history.lines[-1][0]) + 1
|
|
except Exception:
|
|
logger.error('Failed to parse cluster history from DCS: %s', self.history.lines)
|
|
elif self.history.value == '[]':
|
|
return 1
|
|
return 0
|
|
|
|
@property
|
|
def min_version(self) -> Optional[Tuple[int, ...]]:
|
|
"""Lowest Patroni software version found in known members of the cluster."""
|
|
return next(iter(sorted(m.patroni_version for m in self.members if m.patroni_version)), None)
|
|
|
|
|
|
class ReturnFalseException(Exception):
|
|
"""Exception to be caught by the :func:`catch_return_false_exception` decorator."""
|
|
|
|
|
|
def catch_return_false_exception(func: Callable[..., Any]) -> Any:
|
|
"""Decorator function for catching functions raising :exc:`ReturnFalseException`.
|
|
|
|
:param func: function to be wrapped.
|
|
|
|
:returns: wrapped function.
|
|
"""
|
|
|
|
def wrapper(*args: Any, **kwargs: Any):
|
|
try:
|
|
return func(*args, **kwargs)
|
|
except ReturnFalseException:
|
|
return False
|
|
|
|
return wrapper
|
|
|
|
|
|
class AbstractDCS(abc.ABC):
|
|
"""Abstract representation of DCS modules.
|
|
|
|
Implementations of a concrete DCS class, using appropriate backend client interfaces, must include the following
|
|
methods and properties.
|
|
|
|
Functional methods that are critical in their timing, required to complete within ``retry_timeout`` period in order
|
|
to prevent the DCS considered inaccessible, each perform construction of complex data objects:
|
|
|
|
* :meth:`~AbstractDCS._postgresql_cluster_loader`:
|
|
method which processes the structure of data stored in the DCS used to build the :class:`Cluster` object
|
|
with all relevant associated data.
|
|
* :meth:`~AbstractDCS._mpp_cluster_loader`:
|
|
Similar to above but specifically representing MPP group and workers information.
|
|
* :meth:`~AbstractDCS._load_cluster`:
|
|
main method for calling specific ``loader`` method to build the :class:`Cluster` object representing the
|
|
state and topology of the cluster.
|
|
|
|
Functional methods that are critical in their timing and must be written with ACID transaction properties in mind:
|
|
|
|
* :meth:`~AbstractDCS.attempt_to_acquire_leader`:
|
|
method used in the leader race to attempt to acquire the leader lock by creating the leader key in the DCS,
|
|
if it does not exist.
|
|
* :meth:`~AbstractDCS._update_leader`:
|
|
method to update ``leader`` key in DCS. Relies on Compare-And-Set to ensure the Primary lock key is updated.
|
|
If this fails to update within the ``retry_timeout`` window the Primary will be demoted.
|
|
|
|
Functional method that relies on Compare-And-Create to ensure only one member creates the relevant key:
|
|
|
|
* :meth:`~AbstractDCS.initialize`:
|
|
method used in the race for cluster initialization which creates the ``initialize`` key in the DCS.
|
|
|
|
DCS backend getter and setter methods and properties:
|
|
|
|
* :meth:`~AbstractDCS.take_leader`: method to create a new leader key in the DCS.
|
|
* :meth:`~AbstractDCS.set_ttl`: method for setting TTL value in DCS.
|
|
* :meth:`~AbstractDCS.ttl`: property which returns the current TTL.
|
|
* :meth:`~AbstractDCS.set_retry_timeout`: method for setting ``retry_timeout`` in DCS backend.
|
|
* :meth:`~AbstractDCS._write_leader_optime`: compatibility method to write WAL LSN to DCS.
|
|
* :meth:`~AbstractDCS._write_status`: method to write WAL LSN for slots to the DCS.
|
|
* :meth:`~AbstractDCS._write_failsafe`: method to write cluster topology to the DCS, used by failsafe mechanism.
|
|
* :meth:`~AbstractDCS.touch_member`: method to update individual member key in the DCS.
|
|
* :meth:`~AbstractDCS.set_history_value`: method to set the ``history`` key in the DCS.
|
|
|
|
DCS setter methods using Compare-And-Set which although important are less critical if they fail, attempts can be
|
|
retried or may result in warning log messages:
|
|
|
|
* :meth:`~AbstractDCS.set_failover_value`: method to create and/or update the ``failover`` key in the DCS.
|
|
* :meth:`~AbstractDCS.set_config_value`: method to create and/or update the ``failover`` key in the DCS.
|
|
* :meth:`~AbstractDCS.set_sync_state_value`: method to set the synchronous state ``sync`` key in the DCS.
|
|
|
|
DCS data and key removal methods:
|
|
|
|
* :meth:`~AbstractDCS.delete_sync_state`:
|
|
likewise, a method to remove synchronous state ``sync`` key from the DCS.
|
|
* :meth:`~AbstractDCS.delete_cluster`:
|
|
method which will remove cluster information from the DCS. Used only from `patronictl`.
|
|
* :meth:`~AbstractDCS._delete_leader`:
|
|
method relies on CAS, used by a member that is the current leader, to remove the ``leader`` key in the DCS.
|
|
* :meth:`~AbstractDCS.cancel_initialization`:
|
|
method to remove the ``initialize`` key for the cluster from the DCS.
|
|
|
|
If either of the `sync_state` set or delete methods fail, although not critical, this may result in
|
|
``Synchronous replication key updated by someone else`` messages being logged.
|
|
|
|
Care should be taken to consult each abstract method for any additional information and requirements such as
|
|
expected exceptions that should be raised in certain conditions and the object types for arguments and return from
|
|
methods and properties.
|
|
"""
|
|
|
|
_INITIALIZE = 'initialize'
|
|
_CONFIG = 'config'
|
|
_LEADER = 'leader'
|
|
_FAILOVER = 'failover'
|
|
_HISTORY = 'history'
|
|
_MEMBERS = 'members/'
|
|
_OPTIME = 'optime'
|
|
_STATUS = 'status' # JSON, contains "leader_lsn" and confirmed_flush_lsn of logical "slots" on the leader
|
|
_LEADER_OPTIME = _OPTIME + '/' + _LEADER # legacy
|
|
_SYNC = 'sync'
|
|
_FAILSAFE = 'failsafe'
|
|
|
|
def __init__(self, config: Dict[str, Any], mpp: 'AbstractMPP') -> None:
|
|
"""Prepare DCS paths, MPP object, initial values for state information and processing dependencies.
|
|
|
|
:param config: :class:`dict`, reference to config section of selected DCS.
|
|
i.e.: ``zookeeper`` for zookeeper, ``etcd`` for etcd, etc...
|
|
:param mpp: an object implementing :class:`AbstractMPP` interface.
|
|
"""
|
|
self._mpp = mpp
|
|
self._name = config['name']
|
|
self._base_path = re.sub('/+', '/', '/'.join(['', config.get('namespace', 'service'), config['scope']]))
|
|
self._set_loop_wait(config.get('loop_wait', 10))
|
|
|
|
self._ctl = bool(config.get('patronictl', False))
|
|
self._cluster: Optional[Cluster] = None
|
|
self._cluster_valid_till: float = 0
|
|
self._cluster_thread_lock = Lock()
|
|
self._last_lsn: int = 0
|
|
self._last_seen: int = 0
|
|
self._last_status: Dict[str, Any] = {'retain_slots': []}
|
|
self._last_retain_slots: Dict[str, float] = {}
|
|
self._last_failsafe: Optional[Dict[str, str]] = {}
|
|
self.event = Event()
|
|
|
|
@property
|
|
def mpp(self) -> 'AbstractMPP':
|
|
"""Get the effective underlying MPP, if any has been configured."""
|
|
return self._mpp
|
|
|
|
def client_path(self, path: str) -> str:
|
|
"""Construct the absolute key name from appropriate parts for the DCS type.
|
|
|
|
:param path: The key name within the current Patroni cluster.
|
|
|
|
:returns: absolute key name for the current Patroni cluster.
|
|
"""
|
|
components = [self._base_path]
|
|
if self._mpp.is_enabled():
|
|
components.append(str(self._mpp.group))
|
|
components.append(path.lstrip('/'))
|
|
return '/'.join(components)
|
|
|
|
@property
|
|
def initialize_path(self) -> str:
|
|
"""Get the client path for ``initialize``."""
|
|
return self.client_path(self._INITIALIZE)
|
|
|
|
@property
|
|
def config_path(self) -> str:
|
|
"""Get the client path for ``config``."""
|
|
return self.client_path(self._CONFIG)
|
|
|
|
@property
|
|
def members_path(self) -> str:
|
|
"""Get the client path for ``members``."""
|
|
return self.client_path(self._MEMBERS)
|
|
|
|
@property
|
|
def member_path(self) -> str:
|
|
"""Get the client path for ``member`` representing this node."""
|
|
return self.client_path(self._MEMBERS + self._name)
|
|
|
|
@property
|
|
def leader_path(self) -> str:
|
|
"""Get the client path for ``leader``."""
|
|
return self.client_path(self._LEADER)
|
|
|
|
@property
|
|
def failover_path(self) -> str:
|
|
"""Get the client path for ``failover``."""
|
|
return self.client_path(self._FAILOVER)
|
|
|
|
@property
|
|
def history_path(self) -> str:
|
|
"""Get the client path for ``history``."""
|
|
return self.client_path(self._HISTORY)
|
|
|
|
@property
|
|
def status_path(self) -> str:
|
|
"""Get the client path for ``status``."""
|
|
return self.client_path(self._STATUS)
|
|
|
|
@property
|
|
def leader_optime_path(self) -> str:
|
|
"""Get the client path for ``optime/leader`` (legacy key, superseded by ``status``)."""
|
|
return self.client_path(self._LEADER_OPTIME)
|
|
|
|
@property
|
|
def sync_path(self) -> str:
|
|
"""Get the client path for ``sync``."""
|
|
return self.client_path(self._SYNC)
|
|
|
|
@property
|
|
def failsafe_path(self) -> str:
|
|
"""Get the client path for ``failsafe``."""
|
|
return self.client_path(self._FAILSAFE)
|
|
|
|
@abc.abstractmethod
|
|
def set_ttl(self, ttl: int) -> Optional[bool]:
|
|
"""Set the new *ttl* value for DCS keys."""
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def ttl(self) -> int:
|
|
"""Get current ``ttl`` value."""
|
|
|
|
@abc.abstractmethod
|
|
def set_retry_timeout(self, retry_timeout: int) -> None:
|
|
"""Set the new value for *retry_timeout*."""
|
|
|
|
def _set_loop_wait(self, loop_wait: int) -> None:
|
|
"""Set new *loop_wait* value.
|
|
|
|
:param loop_wait: value to set.
|
|
"""
|
|
self._loop_wait = loop_wait
|
|
|
|
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
|
|
"""Load and set relevant values from configuration.
|
|
|
|
Sets ``loop_wait``, ``ttl`` and ``retry_timeout`` properties.
|
|
|
|
:param config: Loaded configuration information object or dictionary of key value pairs.
|
|
"""
|
|
self._set_loop_wait(config['loop_wait'])
|
|
self.set_ttl(config['ttl'])
|
|
self.set_retry_timeout(config['retry_timeout'])
|
|
|
|
@property
|
|
def loop_wait(self) -> int:
|
|
"""The recorded value for cluster HA loop wait time in seconds."""
|
|
return self._loop_wait
|
|
|
|
@property
|
|
def last_seen(self) -> int:
|
|
"""The time recorded when the DCS was last reachable."""
|
|
return self._last_seen
|
|
|
|
@abc.abstractmethod
|
|
def _postgresql_cluster_loader(self, path: Any) -> Cluster:
|
|
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
|
|
|
:param path: the path in DCS where to load :class:`Cluster` from.
|
|
|
|
:returns: :class:`Cluster` instance.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def _mpp_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
|
|
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
|
|
|
:param path: the path in DCS where to load Cluster(s) from.
|
|
|
|
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def _load_cluster(
|
|
self, path: str, loader: Callable[[Any], Union[Cluster, Dict[int, Cluster]]]
|
|
) -> Union[Cluster, Dict[int, Cluster]]:
|
|
"""Main abstract method that implements the loading of :class:`Cluster` instance.
|
|
|
|
.. note::
|
|
Internally this method should call the *loader* method that will build :class:`Cluster` object which
|
|
represents current state and topology of the cluster in DCS. This method supposed to be called only by
|
|
the :meth:`~AbstractDCS.get_cluster` method.
|
|
|
|
:param path: the path in DCS where to load Cluster(s) from.
|
|
:param loader: one of :meth:`~AbstractDCS._postgresql_cluster_loader` or
|
|
:meth:`~AbstractDCS._mpp_cluster_loader`.
|
|
|
|
:raise: :exc:`~DCSError` in case of communication problems with DCS. If the current node was running as a
|
|
primary and exception raised, instance would be demoted.
|
|
"""
|
|
|
|
def __get_postgresql_cluster(self, path: Optional[str] = None) -> Cluster:
|
|
"""Low level method to load a :class:`Cluster` object from DCS.
|
|
|
|
:param path: optional client path in DCS backend to load from.
|
|
|
|
:returns: a loaded :class:`Cluster` instance.
|
|
"""
|
|
if path is None:
|
|
path = self.client_path('')
|
|
cluster = self._load_cluster(path, self._postgresql_cluster_loader)
|
|
if TYPE_CHECKING: # pragma: no cover
|
|
assert isinstance(cluster, Cluster)
|
|
return cluster
|
|
|
|
def is_mpp_coordinator(self) -> bool:
|
|
""":class:`Cluster` instance has a Coordinator group ID.
|
|
|
|
:returns: ``True`` if the given node is running as the MPP Coordinator.
|
|
"""
|
|
return self._mpp.is_coordinator()
|
|
|
|
def get_mpp_coordinator(self) -> Optional[Cluster]:
|
|
"""Load the PostgreSQL cluster for the MPP Coordinator.
|
|
|
|
.. note::
|
|
This method is only executed on the worker nodes to find the coordinator.
|
|
|
|
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
|
|
"""
|
|
try:
|
|
return self.__get_postgresql_cluster(f'{self._base_path}/{self._mpp.coordinator_group_id}/')
|
|
except Exception as e:
|
|
logger.error('Failed to load %s coordinator cluster from %s: %r',
|
|
self._mpp.type, self.__class__.__name__, e)
|
|
return None
|
|
|
|
def _get_mpp_cluster(self) -> Cluster:
|
|
"""Load MPP cluster from DCS.
|
|
|
|
:returns: A MPP :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
|
|
dict.
|
|
"""
|
|
groups = self._load_cluster(self._base_path + '/', self._mpp_cluster_loader)
|
|
if TYPE_CHECKING: # pragma: no cover
|
|
assert isinstance(groups, dict)
|
|
cluster = groups.pop(self._mpp.coordinator_group_id, Cluster.empty())
|
|
cluster.workers.update(groups)
|
|
return cluster
|
|
|
|
def get_cluster(self) -> Cluster:
|
|
"""Retrieve a fresh view of DCS.
|
|
|
|
.. note::
|
|
Stores copy of time, status and failsafe values for comparison in DCS update decisions.
|
|
Caching is required to avoid overhead placed upon the REST API.
|
|
|
|
Returns either a PostgreSQL or MPP implementation of :class:`Cluster` depending on availability.
|
|
|
|
:returns:
|
|
"""
|
|
try:
|
|
cluster = self._get_mpp_cluster() if self.is_mpp_coordinator() else self.__get_postgresql_cluster()
|
|
except Exception:
|
|
self.reset_cluster()
|
|
raise
|
|
|
|
with self._cluster_thread_lock:
|
|
self._cluster = cluster
|
|
self._cluster_valid_till = time.time() + self.ttl
|
|
|
|
self._last_seen = int(time.time())
|
|
self._last_status = {self._OPTIME: cluster.status.last_lsn, 'retain_slots': cluster.status.retain_slots}
|
|
if cluster.status.slots:
|
|
self._last_status['slots'] = cluster.status.slots
|
|
self._last_failsafe = cluster.failsafe
|
|
|
|
return cluster
|
|
|
|
@property
|
|
def cluster(self) -> Optional[Cluster]:
|
|
"""Cached DCS cluster information that has not yet expired."""
|
|
with self._cluster_thread_lock:
|
|
return self._cluster if self._cluster_valid_till > time.time() else None
|
|
|
|
def reset_cluster(self) -> None:
|
|
"""Clear cached state of DCS."""
|
|
with self._cluster_thread_lock:
|
|
self._cluster = None
|
|
self._cluster_valid_till = 0
|
|
|
|
@abc.abstractmethod
|
|
def _write_leader_optime(self, last_lsn: str) -> bool:
|
|
"""Write current WAL LSN into ``/optime/leader`` key in DCS.
|
|
|
|
:param last_lsn: absolute WAL LSN in bytes.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
def write_leader_optime(self, last_lsn: int) -> None:
|
|
"""Write value for WAL LSN to ``optime/leader`` key in DCS.
|
|
|
|
.. note::
|
|
This method abstracts away the required data structure of :meth:`~Cluster.write_status`, so it
|
|
is not needed in the caller. However, the ``optime/leader`` is only written in
|
|
:meth:`~Cluster.write_status` when the cluster has members with a Patroni version that
|
|
is old enough to require it (i.e. the old Patroni version doesn't understand the new format).
|
|
|
|
:param last_lsn: absolute WAL LSN in bytes.
|
|
"""
|
|
self.write_status({self._OPTIME: last_lsn})
|
|
|
|
@abc.abstractmethod
|
|
def _write_status(self, value: str) -> bool:
|
|
"""Write current WAL LSN and ``confirmed_flush_lsn`` of permanent slots into the ``/status`` key in DCS.
|
|
|
|
:param value: status serialized in JSON format.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
def write_status(self, value: Dict[str, Any]) -> None:
|
|
"""Write cluster status to DCS if changed.
|
|
|
|
.. note::
|
|
The DCS key ``/status`` was introduced in Patroni version 2.1.0. Previous to this the position of last known
|
|
leader LSN was stored in ``optime/leader``. This method has detection for backwards compatibility of members
|
|
with a version older than this.
|
|
|
|
:param value: JSON serializable dictionary with current WAL LSN and ``confirmed_flush_lsn`` of permanent slots.
|
|
"""
|
|
# This method is always called with ``optime`` key, rest of the keys are optional.
|
|
# In case if we know old values (stored in self._last_status), we will copy them over.
|
|
for name in ('slots', 'retain_slots'):
|
|
if name not in value and self._last_status.get(name):
|
|
value[name] = self._last_status[name]
|
|
# if the key is present, but the value is None, we will not write such pair.
|
|
value = {k: v for k, v in value.items() if v is not None}
|
|
|
|
if not deep_compare(self._last_status, value) and self._write_status(json.dumps(value, separators=(',', ':'))):
|
|
self._last_status = value
|
|
cluster = self.cluster
|
|
min_version = cluster and cluster.min_version
|
|
if min_version and min_version < (2, 1, 0) and self._last_lsn != value[self._OPTIME]:
|
|
self._last_lsn = value[self._OPTIME]
|
|
self._write_leader_optime(str(value[self._OPTIME]))
|
|
|
|
@abc.abstractmethod
|
|
def _write_failsafe(self, value: str) -> bool:
|
|
"""Write current cluster topology to DCS that will be used by failsafe mechanism (if enabled).
|
|
|
|
:param value: failsafe topology serialized in JSON format.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
def write_failsafe(self, value: Dict[str, str]) -> None:
|
|
"""Write the ``/failsafe`` key in DCS.
|
|
|
|
:param value: dictionary value to set, consisting of the ``name`` and ``api_url`` of members.
|
|
"""
|
|
if not (isinstance(self._last_failsafe, dict) and deep_compare(self._last_failsafe, value)) \
|
|
and self._write_failsafe(json.dumps(value, separators=(',', ':'))):
|
|
self._last_failsafe = value
|
|
|
|
@property
|
|
def failsafe(self) -> Optional[Dict[str, str]]:
|
|
"""Stored value of :attr:`~AbstractDCS._last_failsafe`."""
|
|
return self._last_failsafe
|
|
|
|
def _build_retain_slots(self, cluster: Cluster, slots: Optional[Dict[str, int]]) -> Optional[List[str]]:
|
|
"""Handle retention policy of physical replication slots for cluster members.
|
|
|
|
When the member key is missing we want to keep its replication slot for a while, so that WAL segments
|
|
will not be already absent when it comes back online. It is being solved by storing the list of
|
|
replication slots representing members in the ``retain_slots`` field of the ``/status`` key.
|
|
|
|
This method handles retention policy by keeping the list of such replication slots in memory
|
|
and removing names when they were observed longer than ``member_slots_ttl`` ago.
|
|
|
|
:param cluster: :class:`Cluster` object with information about the current cluster state.
|
|
:param slots: slot names with LSN values that exist on the leader node and consists
|
|
of slots for cluster members and permanent replication slots.
|
|
|
|
:returns: the list of replication slots to be written to ``/status`` key or ``None``.
|
|
"""
|
|
timestamp = time.time()
|
|
|
|
# DCS is a source of truth, therefore we take missing values from there
|
|
self._last_retain_slots.update({name: timestamp for name in self._last_status['retain_slots']
|
|
if (not slots or name not in slots) and name not in self._last_retain_slots})
|
|
|
|
if slots: # if slots is not empty it implies we are running v11+
|
|
members: Set[str] = set()
|
|
found_self = False
|
|
for member in cluster.members:
|
|
found_self = member.name == self._name
|
|
if not member.nostream:
|
|
members.add(slot_name_from_member_name(member.name))
|
|
if not found_self:
|
|
# It could be that the member key for our node is not in DCS and we can't check tags.nostream.
|
|
# In this case our name will falsely appear in `retain_slots`, but only temporary.
|
|
members.add(slot_name_from_member_name(self._name))
|
|
|
|
permanent_slots = cluster.permanent_physical_slots
|
|
# we want to have in ``retain_slots`` only non-permanent member slots
|
|
self._last_retain_slots.update({name: timestamp for name in slots
|
|
if name in members and name not in permanent_slots})
|
|
# retention
|
|
for name, value in list(self._last_retain_slots.items()):
|
|
if value + global_config.member_slots_ttl <= timestamp:
|
|
logger.info("Replication slot '%s' for absent cluster member is expired after %d sec.",
|
|
name, global_config.member_slots_ttl)
|
|
del self._last_retain_slots[name]
|
|
|
|
return list(sorted(self._last_retain_slots.keys())) or None
|
|
|
|
@abc.abstractmethod
|
|
def _update_leader(self, leader: Leader) -> bool:
|
|
"""Update ``leader`` key (or session) ttl.
|
|
|
|
.. note::
|
|
You have to use CAS (Compare And Swap) operation in order to update leader key, for example for etcd
|
|
``prevValue`` parameter must be used.
|
|
|
|
If update fails due to DCS not being accessible or because it is not able to process requests (hopefully
|
|
temporary), the :exc:`DCSError` exception should be raised.
|
|
|
|
:param leader: a reference to a current :class:`leader` object.
|
|
|
|
:returns: ``True`` if ``leader`` key (or session) has been updated successfully.
|
|
"""
|
|
|
|
def update_leader(self,
|
|
cluster: Cluster,
|
|
last_lsn: Optional[int],
|
|
slots: Optional[Dict[str, int]] = None,
|
|
failsafe: Optional[Dict[str, str]] = None) -> bool:
|
|
"""Update ``leader`` key (or session) ttl, ``/status``, and ``/failsafe`` keys.
|
|
|
|
:param cluster: :class:`Cluster` object with information about the current cluster state.
|
|
:param last_lsn: absolute WAL LSN in bytes.
|
|
:param slots: dictionary with permanent slots ``confirmed_flush_lsn``.
|
|
:param failsafe: if defined dictionary passed to :meth:`~AbstractDCS.write_failsafe`.
|
|
|
|
:returns: ``True`` if ``leader`` key (or session) has been updated successfully.
|
|
"""
|
|
if TYPE_CHECKING: # pragma: no cover
|
|
assert isinstance(cluster.leader, Leader)
|
|
ret = self._update_leader(cluster.leader)
|
|
if ret and last_lsn:
|
|
status: Dict[str, Any] = {self._OPTIME: last_lsn, 'slots': slots or None,
|
|
'retain_slots': self._build_retain_slots(cluster, slots)}
|
|
self.write_status(status)
|
|
|
|
if ret and failsafe is not None:
|
|
self.write_failsafe(failsafe)
|
|
|
|
return ret
|
|
|
|
@abc.abstractmethod
|
|
def attempt_to_acquire_leader(self) -> bool:
|
|
"""Attempt to acquire leader lock.
|
|
|
|
.. note::
|
|
This method should create ``/leader`` key with the value :attr:`~AbstractDCS._name`.
|
|
|
|
The key must be created atomically. In case the key already exists it should not be
|
|
overwritten and ``False`` must be returned.
|
|
|
|
If key creation fails due to DCS not being accessible or because it is not able to
|
|
process requests (hopefully temporary), the :exc:`DCSError` exception should be raised.
|
|
|
|
:returns: ``True`` if key has been created successfully.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def set_failover_value(self, value: str, version: Optional[Any] = None) -> bool:
|
|
"""Create or update ``/failover`` key.
|
|
|
|
:param value: value to set.
|
|
:param version: for conditional update of the key/object.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
def manual_failover(self, leader: Optional[str], candidate: Optional[str],
|
|
scheduled_at: Optional[datetime.datetime] = None, version: Optional[Any] = None) -> bool:
|
|
"""Prepare dictionary with given values and set ``/failover`` key in DCS.
|
|
|
|
:param leader: value to set for ``leader``.
|
|
:param candidate: value to set for ``member``.
|
|
:param scheduled_at: value converted to ISO date format for ``scheduled_at``.
|
|
:param version: for conditional update of the key/object.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
failover_value = {}
|
|
if leader:
|
|
failover_value['leader'] = leader
|
|
|
|
if candidate:
|
|
failover_value['member'] = candidate
|
|
|
|
if scheduled_at:
|
|
failover_value['scheduled_at'] = scheduled_at.isoformat()
|
|
return self.set_failover_value(json.dumps(failover_value, separators=(',', ':')), version)
|
|
|
|
@abc.abstractmethod
|
|
def set_config_value(self, value: str, version: Optional[Any] = None) -> bool:
|
|
"""Create or update ``/config`` key in DCS.
|
|
|
|
:param value: new value to set in the ``config`` key.
|
|
:param version: for conditional update of the key/object.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def touch_member(self, data: Dict[str, Any]) -> bool:
|
|
"""Update member key in DCS.
|
|
|
|
.. note::
|
|
This method should create or update key with the name with ``/members/`` + :attr:`~AbstractDCS._name`
|
|
and the value of *data* in a given DCS.
|
|
|
|
:param data: information about an instance (including connection strings).
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def take_leader(self) -> bool:
|
|
"""Establish a new leader in DCS.
|
|
|
|
.. note::
|
|
This method should create leader key with value of :attr:`~AbstractDCS._name` and ``ttl`` of
|
|
:attr:`~AbstractDCS.ttl`.
|
|
|
|
Since it could be called only on initial cluster bootstrap it could create this key regardless,
|
|
overwriting the key if necessary.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def initialize(self, create_new: bool = True, sysid: str = "") -> bool:
|
|
"""Race for cluster initialization.
|
|
|
|
This method should atomically create ``initialize`` key and return ``True``,
|
|
otherwise it should return ``False``.
|
|
|
|
:param create_new: ``False`` if the key should already exist (in the case we are setting the system_id).
|
|
:param sysid: PostgreSQL cluster system identifier, if specified, is written to the key.
|
|
|
|
:returns: ``True`` if key has been created successfully.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def _delete_leader(self, leader: Leader) -> bool:
|
|
"""Remove leader key from DCS.
|
|
|
|
This method should remove leader key if current instance is the leader.
|
|
|
|
:param leader: :class:`Leader` object with information about the leader.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool:
|
|
"""Update ``optime/leader`` and voluntarily remove leader key from DCS.
|
|
|
|
This method should remove leader key if current instance is the leader.
|
|
|
|
:param leader: :class:`Leader` object with information about the leader.
|
|
:param last_lsn: latest checkpoint location in bytes.
|
|
|
|
:returns: boolean result of called abstract :meth:`~AbstractDCS._delete_leader`.
|
|
"""
|
|
if last_lsn:
|
|
self.write_status({self._OPTIME: last_lsn})
|
|
return bool(leader) and self._delete_leader(leader)
|
|
|
|
@abc.abstractmethod
|
|
def cancel_initialization(self) -> bool:
|
|
"""Removes the ``initialize`` key for a cluster.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def delete_cluster(self) -> bool:
|
|
"""Delete cluster from DCS.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
@staticmethod
|
|
def sync_state(leader: Optional[str], sync_standby: Optional[Collection[str]],
|
|
quorum: Optional[int]) -> Dict[str, Any]:
|
|
"""Build ``sync_state`` dictionary.
|
|
|
|
:param leader: name of the leader node that manages ``/sync`` key.
|
|
:param sync_standby: collection of currently known synchronous standby node names.
|
|
:param quorum: if the node from :attr:`~SyncState.sync_standby` list is doing a leader race it should
|
|
see at least :attr:`~SyncState.quorum` other nodes from the
|
|
:attr:`~SyncState.sync_standby` + :attr:`~SyncState.leader` list
|
|
|
|
:returns: dictionary that later could be serialized to JSON or saved directly to DCS.
|
|
"""
|
|
return {'leader': leader, 'quorum': quorum,
|
|
'sync_standby': ','.join(sorted(sync_standby)) if sync_standby else None}
|
|
|
|
def write_sync_state(self, leader: Optional[str], sync_standby: Optional[Collection[str]],
|
|
quorum: Optional[int], version: Optional[Any] = None) -> Optional[SyncState]:
|
|
"""Write the new synchronous state to DCS.
|
|
|
|
Calls :meth:`~AbstractDCS.sync_state` to build a dictionary and then calls DCS specific
|
|
:meth:`~AbstractDCS.set_sync_state_value`.
|
|
|
|
:param leader: name of the leader node that manages ``/sync`` key.
|
|
:param sync_standby: collection of currently known synchronous standby node names.
|
|
:param version: for conditional update of the key/object.
|
|
:param quorum: if the node from :attr:`~SyncState.sync_standby` list is doing a leader race it should
|
|
see at least :attr:`~SyncState.quorum` other nodes from the
|
|
:attr:`~SyncState.sync_standby` + :attr:`~SyncState.leader` list
|
|
|
|
:returns: the new :class:`SyncState` object or ``None``.
|
|
"""
|
|
sync_value = self.sync_state(leader, sync_standby, quorum)
|
|
ret = self.set_sync_state_value(json.dumps(sync_value, separators=(',', ':')), version)
|
|
if not isinstance(ret, bool):
|
|
return SyncState.from_node(ret, sync_value)
|
|
return None
|
|
|
|
@abc.abstractmethod
|
|
def set_history_value(self, value: str) -> bool:
|
|
"""Set value for ``history`` in DCS.
|
|
|
|
:param value: new value of ``history`` key/object.
|
|
|
|
:returns: ``True`` if successfully committed to DCS.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def set_sync_state_value(self, value: str, version: Optional[Any] = None) -> Union[Any, bool]:
|
|
"""Set synchronous state in DCS.
|
|
|
|
:param value: the new value of ``/sync`` key.
|
|
:param version: for conditional update of the key/object.
|
|
|
|
:returns: *version* of the new object or ``False`` in case of error.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def delete_sync_state(self, version: Optional[Any] = None) -> bool:
|
|
"""Delete the synchronous state from DCS.
|
|
|
|
:param version: for conditional deletion of the key/object.
|
|
|
|
:returns: ``True`` if delete successful.
|
|
"""
|
|
|
|
def watch(self, leader_version: Optional[Any], timeout: float) -> bool:
|
|
"""Sleep if the current node is a leader, otherwise, watch for changes of leader key with a given *timeout*.
|
|
|
|
:param leader_version: version of a leader key.
|
|
:param timeout: timeout in seconds.
|
|
|
|
:returns: if ``True`` this will reschedule the next run of the HA cycle.
|
|
"""
|
|
_ = leader_version
|
|
self.event.wait(timeout)
|
|
return self.event.is_set()
|