Compare commits

...
Author SHA1 Message Date
Alexander Kukushkin c0033399a5 Merge branch 'release/v3.3.0' of github.com:zalando/patroni into feature/citus-secondaries 2024-04-04 08:50:27 +02:00
Polina Bungina ce05cb9a10 Add release notes for 3.3.0 2024-04-03 21:16:32 +02:00
Alexander Kukushkin 4469c1c390 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2024-04-03 09:38:12 +02:00
Alexander Kukushkin 68c7b11970 Improve unit-tests code coverage 2024-04-02 15:46:59 +02:00
Alexander Kukushkin ced75cfe12 Bump pyright and "solve" reported "issues"
Most of them are related to partially unknown types of values from empty
dict or list. To solve it for the empty dict we use `EMPTY_DICT` object of
newly introduced `_FrozenDict` class.
2024-04-02 15:15:52 +02:00
Alexander Kukushkin 25ee08f62f Make sure unit tests not rely on filesystem state 2024-04-02 15:02:12 +02:00
Alexander Kukushkin 3e2f553c81 Make sure tests are not making external calls
and pass url with scheme to urllib3 to avoid warnings
2024-04-02 15:02:12 +02:00
Alexander Kukushkin ca7188bfdb Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2024-04-02 12:13:12 +02:00
Alexander Kukushkin 27a1a39f75 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2024-01-05 10:20:11 +01:00
Alexander Kukushkin f99fff6c6a Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-12-21 09:42:29 +01:00
Alexander Kukushkin 63ffb6320f Fix oversight of rebace 2023-12-01 11:35:20 +01:00
Alexander Kukushkin d7b4b4e8a9 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-12-01 10:20:21 +01:00
Alexander Kukushkin 84042f3297 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-09-11 15:54:24 +02:00
Alexander Kukushkin a49c534803 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-24 16:32:19 +02:00
Alexander Kukushkin c4f95200bc Move _node_hash to PgDistNode and call it as_tuple.
And more work on docstrings
2023-08-24 12:09:06 +02:00
Alexander Kukushkin e96b77c7aa Address review feedback 2023-08-24 09:59:18 +02:00
Alexander Kukushkin b3b3493f3d Rename group to groupid 2023-08-23 16:08:47 +02:00
Alexander Kukushkin c3bba15ce1 Address review feedback 2023-08-23 09:38:46 +02:00
Alexander Kukushkin d5063bd3d7 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-23 09:36:34 +02:00
Alexander Kukushkin 36bb077964 Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-18 11:10:35 +02:00
Alexander Kukushkin 74ed88611f Please sphinx 2023-08-17 13:27:59 +02:00
Alexander Kukushkin e54b88534c Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-17 13:16:01 +02:00
Alexander Kukushkin a6e05b240c Merge branch 'master' of github.com:zalando/patroni into feature/citus-secondaries 2023-08-02 10:12:33 +02:00
Alexander Kukushkin 9ba40f0a25 Register Citus secondaries in pg_dist_node
1. All nodes with role == 'replica' and state == 'running' are
   are registered. In case is state isn't running the node is removed.
2. In case of failover/switchover we always first update the primary
3. When switching to a registered secondary we call citus_update_node()
   three times: rename primary to primary-demoted, put the primary name
   to a promoted secondary row and put the promoted secondary name to
   the primary row

State transitions are produced by the transition() method. First of all
the method makes sure that the actual primary is registered in the
metadata. In case if for a given group the primary didn't change, the
method registers new secondaries and removes secondaries that are gone.
It prefers to use citus_update_node() UDF to replace gone secondaries
with added.

Communication protocol between primary nodes remains the same and all
old features work without any changes.
2023-07-13 15:14:07 +02:00
26 changed files with 844 additions and 168 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ jobs:
- uses: jakebailey/pyright-action@v1 - uses: jakebailey/pyright-action@v1
with: with:
version: 1.1.347 version: 1.1.356
docs: docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+61
View File
@@ -3,6 +3,67 @@
Release notes Release notes
============= =============
Version 3.3.0
-------------
**New features**
- Add ability to pass ``auth_data`` to Zookeeper client (Aras Mumcuyan)
It allows to specify the authentication credentials to use for the connection.
- Add a contrib script for ``Barman`` integration (Israel Barth Rubio)
Provide an application ``patroni_barman`` that allows to perform ``Barman`` operations remotely and can be used as a custom bootstrap/custom replica method or as an ``on_role_change`` callback. Please check :ref:`here <tools_integration>` for more information.
- Support JSON log format (alisalemmi)
Apart from ``plain``, Patroni now also supports ``json`` log format. Requires ``python-json-logger`` library to be installed.
- Show ``pending_restart_reason`` information (Polina Bungina)
Provide extended information about the PostgreSQL parameters that caused ``pending_restart`` flag to be set. Both ``patronictl list`` and ``/patroni`` REST API endpoint now show the parameters names and their "diff" as ``pending_restart_reason``.
- Implement ``nostream`` tag (Grigory Smolkin)
If ``nostream`` tag is set to ``true``, the node will not use replication protocol to stream WAL but instead rely on archive recovery (if ``restore_command`` is configured). It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas.
**Improvements**
- Implement validation of the log section (Alexander Kukushkin)
Until now validator was not checking the correctness of the logging configuration provided.
- Improve logging for PostgreSQL parameters change (Polina Bungina)
Convert old values to a human-readable format and log information about the ``pg_controldata`` vs Patroni global configuration mismatch.
**Bugfixes**
- Properly filter out not allowed ``pg_basebackup`` options (Israel Barth Rubio)
Due to a bug, Patroni was not properly filtering out the not allowed options configured for the ``basebackup`` replica bootstrap method, when provided in the ``- setting: value`` format.
- Fix ``etcd3`` authentication error handling (Alexander Kukushkin)
Always retry one time on ``etcd3`` authentication error if authentication was not done right before executing the request. Also, do not restart watchers on reauthentication.
- Improve logic of the validator files discovery (Waynerv)
Use ``importlib`` library to discover the files with available configuration parameters when possible (for Python 3.9+). This implementation is more stable and doesn't break the Patroni distributions based on ``zip`` archives.
- Use ``target_session_attrs`` only when multiple hosts are specified in the ``standby_cluster`` section (Alexander Kukushkin)
``target_session_attrs=read-write`` is now added to the ``primary_conninfo`` on the standby leader node only when ``standby_cluster.host`` section contains multiple hosts separated by commas.
- Add compatibility code for ``ydiff`` library version 1.3+ (Alexander Kukushkin)
.. warning::
All older Partoni versions are not compatible with ``ydiff`` 1.3+. Please upgrade Patroni, use ``ydiff`` version <1.3, or install ``cdiff``.
Version 3.2.2 Version 3.2.2
------------- -------------
+2
View File
@@ -1,3 +1,5 @@
.. _tools_integration:
Integration with other tools Integration with other tools
============================ ============================
+7
View File
@@ -11,7 +11,9 @@ Feature: citus
Then replication works from postgres0 to postgres1 after 15 seconds Then replication works from postgres0 to postgres1 after 15 seconds
Then replication works from postgres2 to postgres3 after 15 seconds Then replication works from postgres2 to postgres3 after 15 seconds
And postgres0 is registered in the postgres0 as the primary in group 0 after 5 seconds And postgres0 is registered in the postgres0 as the primary in group 0 after 5 seconds
And postgres1 is registered in the postgres0 as the secondary in group 0 after 5 seconds
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And postgres3 is registered in the postgres0 as the secondary in group 1 after 5 seconds
Scenario: coordinator failover updates pg_dist_node Scenario: coordinator failover updates pg_dist_node
Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force Given I run patronictl.py failover batman --group 0 --candidate postgres1 --force
@@ -19,11 +21,13 @@ Feature: citus
And "members/postgres0" key in a group 0 in DCS has state=running after 15 seconds And "members/postgres0" key in a group 0 in DCS has state=running after 15 seconds
And replication works from postgres1 to postgres0 after 15 seconds And replication works from postgres1 to postgres0 after 15 seconds
And postgres1 is registered in the postgres2 as the primary in group 0 after 5 seconds And postgres1 is registered in the postgres2 as the primary in group 0 after 5 seconds
And postgres0 is registered in the postgres2 as the secondary in group 0 after 15 seconds
And "sync" key in a group 0 in DCS has sync_standby=postgres0 after 15 seconds And "sync" key in a group 0 in DCS has sync_standby=postgres0 after 15 seconds
When I run patronictl.py switchover batman --group 0 --candidate postgres0 --force When I run patronictl.py switchover batman --group 0 --candidate postgres0 --force
Then postgres0 role is the primary after 10 seconds Then postgres0 role is the primary after 10 seconds
And replication works from postgres0 to postgres1 after 15 seconds And replication works from postgres0 to postgres1 after 15 seconds
And postgres0 is registered in the postgres2 as the primary in group 0 after 5 seconds And postgres0 is registered in the postgres2 as the primary in group 0 after 5 seconds
And postgres1 is registered in the postgres2 as the secondary in group 0 after 15 seconds
And "sync" key in a group 0 in DCS has sync_standby=postgres1 after 15 seconds And "sync" key in a group 0 in DCS has sync_standby=postgres1 after 15 seconds
Scenario: worker switchover doesn't break client queries on the coordinator Scenario: worker switchover doesn't break client queries on the coordinator
@@ -35,6 +39,7 @@ Feature: citus
And "members/postgres2" key in a group 1 in DCS has state=running after 15 seconds And "members/postgres2" key in a group 1 in DCS has state=running after 15 seconds
And replication works from postgres3 to postgres2 after 15 seconds And replication works from postgres3 to postgres2 after 15 seconds
And postgres3 is registered in the postgres0 as the primary in group 1 after 5 seconds And postgres3 is registered in the postgres0 as the primary in group 1 after 5 seconds
And postgres2 is registered in the postgres0 as the secondary in group 1 after 15 seconds
And "sync" key in a group 1 in DCS has sync_standby=postgres2 after 15 seconds And "sync" key in a group 1 in DCS has sync_standby=postgres2 after 15 seconds
And a thread is still alive And a thread is still alive
When I run patronictl.py switchover batman --group 1 --force When I run patronictl.py switchover batman --group 1 --force
@@ -42,6 +47,7 @@ Feature: citus
And postgres2 role is the primary after 10 seconds And postgres2 role is the primary after 10 seconds
And replication works from postgres2 to postgres3 after 15 seconds And replication works from postgres2 to postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And postgres3 is registered in the postgres0 as the secondary in group 1 after 15 seconds
And "sync" key in a group 1 in DCS has sync_standby=postgres3 after 15 seconds And "sync" key in a group 1 in DCS has sync_standby=postgres3 after 15 seconds
And a thread is still alive And a thread is still alive
When I stop a thread When I stop a thread
@@ -55,6 +61,7 @@ Feature: citus
And postgres2 role is the primary after 10 seconds And postgres2 role is the primary after 10 seconds
And replication works from postgres2 to postgres3 after 15 seconds And replication works from postgres2 to postgres3 after 15 seconds
And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds And postgres2 is registered in the postgres0 as the primary in group 1 after 5 seconds
And postgres3 is registered in the postgres0 as the secondary in group 1 after 15 seconds
And a thread is still alive And a thread is still alive
When I stop a thread When I stop a thread
Then a distributed table on postgres0 has expected rows Then a distributed table on postgres0 has expected rows
+49 -4
View File
@@ -1,9 +1,10 @@
"""Patroni custom object types somewhat like :mod:`collections` module. """Patroni custom object types somewhat like :mod:`collections` module.
Provides a case insensitive :class:`dict` and :class:`set` object types. Provides a case insensitive :class:`dict` and :class:`set` object types, and `EMPTY_DICT` frozen dictionary object.
""" """
from collections import OrderedDict from collections import OrderedDict
from typing import Any, Collection, Dict, Iterator, KeysView, MutableMapping, MutableSet, Optional from copy import deepcopy
from typing import Any, Collection, Dict, Iterator, KeysView, Mapping, MutableMapping, MutableSet, Optional
class CaseInsensitiveSet(MutableSet[str]): class CaseInsensitiveSet(MutableSet[str]):
@@ -48,7 +49,7 @@ class CaseInsensitiveSet(MutableSet[str]):
""" """
return str(set(self._values.values())) return str(set(self._values.values()))
def __contains__(self, value: str) -> bool: def __contains__(self, value: object) -> bool:
"""Check if set contains *value*. """Check if set contains *value*.
The check is performed case-insensitively. The check is performed case-insensitively.
@@ -57,7 +58,7 @@ class CaseInsensitiveSet(MutableSet[str]):
:returns: ``True`` if *value* is already in the set, ``False`` otherwise. :returns: ``True`` if *value* is already in the set, ``False`` otherwise.
""" """
return value.lower() in self._values return isinstance(value, str) and value.lower() in self._values
def __iter__(self) -> Iterator[str]: def __iter__(self) -> Iterator[str]:
"""Iterate over the values in this set. """Iterate over the values in this set.
@@ -207,3 +208,47 @@ class CaseInsensitiveDict(MutableMapping[str, Any]):
"<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..." "<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..."
""" """
return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self)) return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self))
class _FrozenDict(Mapping[str, Any]):
"""Frozen dictionary object."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Create a new instance of :class:`_FrozenDict` with given data."""
self.__values: Dict[str, Any] = dict(*args, **kwargs)
def __iter__(self) -> Iterator[str]:
"""Iterate over keys of this dict.
:yields: each key present in the dict. Yields each key with its last case that has been stored.
"""
return iter(self.__values)
def __len__(self) -> int:
"""Get the length of this dict.
:returns: number of keys in the dict.
:Example:
>>> len(_FrozenDict())
0
"""
return len(self.__values)
def __getitem__(self, key: str) -> Any:
"""Get the value corresponding to *key*.
:returns: value corresponding to *key*.
"""
return self.__values[key]
def copy(self) -> Dict[str, Any]:
"""Create a copy of this dict.
:return: a new dict object with the same keys and values of this dict.
"""
return deepcopy(self.__values)
EMPTY_DICT = _FrozenDict()
+3 -3
View File
@@ -12,7 +12,7 @@ from copy import deepcopy
from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING
from . import PATRONI_ENV_PREFIX from . import PATRONI_ENV_PREFIX
from .collections import CaseInsensitiveDict from .collections import CaseInsensitiveDict, EMPTY_DICT
from .dcs import ClusterConfig from .dcs import ClusterConfig
from .exceptions import ConfigParseError from .exceptions import ConfigParseError
from .file_perm import pg_perm from .file_perm import pg_perm
@@ -445,14 +445,14 @@ class Config(object):
for name, value in dynamic_configuration.items(): for name, value in dynamic_configuration.items():
if name == 'postgresql': if name == 'postgresql':
for name, value in (value or {}).items(): for name, value in (value or EMPTY_DICT).items():
if name == 'parameters': if name == 'parameters':
config['postgresql'][name].update(self._process_postgresql_parameters(value)) config['postgresql'][name].update(self._process_postgresql_parameters(value))
elif name not in ('connect_address', 'proxy_address', 'listen', elif name not in ('connect_address', 'proxy_address', 'listen',
'config_dir', 'data_dir', 'pgpass', 'authentication'): 'config_dir', 'data_dir', 'pgpass', 'authentication'):
config['postgresql'][name] = deepcopy(value) config['postgresql'][name] = deepcopy(value)
elif name == 'standby_cluster': elif name == 'standby_cluster':
for name, value in (value or {}).items(): for name, value in (value or EMPTY_DICT).items():
if name in self.__DEFAULT_CONFIG['standby_cluster']: if name in self.__DEFAULT_CONFIG['standby_cluster']:
config['standby_cluster'][name] = deepcopy(value) config['standby_cluster'][name] = deepcopy(value)
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
+7 -3
View File
@@ -15,6 +15,7 @@ if TYPE_CHECKING: # pragma: no cover
from psycopg2 import cursor from psycopg2 import cursor
from . import psycopg from . import psycopg
from .collections import EMPTY_DICT
from .config import Config from .config import Config
from .exceptions import PatroniException from .exceptions import PatroniException
from .log import PatroniLogger from .log import PatroniLogger
@@ -244,7 +245,8 @@ class SampleConfigGenerator(AbstractConfigGenerator):
See :func:`~patroni.postgresql.misc.postgres_major_version_to_int` and See :func:`~patroni.postgresql.misc.postgres_major_version_to_int` and
:func:`~patroni.utils.get_major_version`. :func:`~patroni.utils.get_major_version`.
""" """
postgres_bin = ((self.config.get('postgresql') or {}).get('bin_name') or {}).get('postgres', 'postgres') postgres_bin = ((self.config.get('postgresql')
or EMPTY_DICT).get('bin_name') or EMPTY_DICT).get('postgres', 'postgres')
return postgres_major_version_to_int(get_major_version(self.config['postgresql'].get('bin_dir'), postgres_bin)) return postgres_major_version_to_int(get_major_version(self.config['postgresql'].get('bin_dir'), postgres_bin))
def generate(self) -> None: def generate(self) -> None:
@@ -411,8 +413,10 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
val = self.parsed_dsn.get(conn_param, os.getenv(env_var)) val = self.parsed_dsn.get(conn_param, os.getenv(env_var))
if val: if val:
su_params[conn_param] = val su_params[conn_param] = val
patroni_env_su_username = ((self.config.get('authentication') or {}).get('superuser') or {}).get('username') patroni_env_su_username = ((self.config.get('authentication')
patroni_env_su_pwd = ((self.config.get('authentication') or {}).get('superuser') or {}).get('password') or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('username')
patroni_env_su_pwd = ((self.config.get('authentication')
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('password')
# because we use "username" in the config for some reason # because we use "username" in the config for some reason
su_params['username'] = su_params.pop('user', patroni_env_su_username) or getuser() su_params['username'] = su_params.pop('user', patroni_env_su_username) or getuser()
su_params['password'] = su_params.get('password', patroni_env_su_pwd) or \ su_params['password'] = su_params.get('password', patroni_env_su_pwd) or \
+4
View File
@@ -85,6 +85,8 @@ def dcs_modules() -> List[str]:
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``. :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__) return iter_modules(__package__)
@@ -101,6 +103,8 @@ def iter_dcs_classes(
:returns: an iterator of tuples, each containing the module ``name`` and the imported DCS class object. :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) return iter_classes(__package__, AbstractDCS, config)
+2 -1
View File
@@ -444,8 +444,9 @@ class Consul(AbstractDCS):
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values. :returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
""" """
results: Optional[List[Dict[str, Any]]]
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency) _, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict) clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
for node in results or []: for node in results or []:
key = node['Key'][len(path):].split('/', 1) key = node['Key'][len(path):].split('/', 1)
if len(key) == 2 and self._mpp.group_re.match(key[0]): if len(key) == 2 and self._mpp.group_re.match(key[0]):
+14 -11
View File
@@ -20,6 +20,7 @@ from threading import Condition, Lock, Thread
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..collections import EMPTY_DICT
from ..exceptions import DCSError from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \ from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
@@ -470,7 +471,7 @@ class K8sClient(object):
if len(args) == 3: # name, namespace, body if len(args) == 3: # name, namespace, body
body = args[2] body = args[2]
elif action == 'create': # namespace, body elif action == 'create': # namespace, body
body = args[1] body = args[1] # pyright: ignore [reportGeneralTypeIssues]
elif action == 'delete': # name, namespace elif action == 'delete': # name, namespace
body = kwargs.pop('body', None) body = kwargs.pop('body', None)
else: else:
@@ -509,7 +510,7 @@ class KubernetesRetriableException(k8s_client.rest.ApiException):
@property @property
def sleeptime(self) -> Optional[int]: def sleeptime(self) -> Optional[int]:
try: try:
return int((self.headers or {}).get('retry-after', '')) return int((self.headers or EMPTY_DICT).get('retry-after', ''))
except Exception: except Exception:
return None return None
@@ -654,7 +655,7 @@ class ObjectCache(Thread):
obj = K8sObject(obj) obj = K8sObject(obj)
success, old_value = self.set(name, obj) success, old_value = self.set(name, obj)
if success: if success:
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name)) new_value = (obj.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
elif ev_type == 'DELETED': elif ev_type == 'DELETED':
success, old_value = self.delete(name, obj['metadata']['resourceVersion']) success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
else: else:
@@ -662,7 +663,7 @@ class ObjectCache(Thread):
if success and obj.get('kind') != 'Pod': if success and obj.get('kind') != 'Pod':
if old_value: if old_value:
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name)) old_value = (old_value.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
value_changed = old_value != new_value and \ value_changed = old_value != new_value and \
(name != self._dcs.config_path or old_value is not None and new_value is not None) (name != self._dcs.config_path or old_value is not None and new_value is not None)
@@ -844,7 +845,7 @@ class Kubernetes(AbstractDCS):
@staticmethod @staticmethod
def member(pod: K8sObject) -> Member: def member(pod: K8sObject) -> Member:
annotations = pod.metadata.annotations or {} annotations = pod.metadata.annotations or EMPTY_DICT
member = Member.from_node(pod.metadata.resource_version, pod.metadata.name, None, annotations.get('status', '')) member = Member.from_node(pod.metadata.resource_version, pod.metadata.name, None, annotations.get('status', ''))
member.data['pod_labels'] = pod.metadata.labels member.data['pod_labels'] = pod.metadata.labels
return member return member
@@ -925,7 +926,7 @@ class Kubernetes(AbstractDCS):
failover = nodes.get(path + self._FAILOVER) failover = nodes.get(path + self._FAILOVER)
metadata = failover and failover.metadata metadata = failover and failover.metadata
failover = metadata and Failover.from_node(metadata.resource_version, failover = metadata and Failover.from_node(metadata.resource_version,
(metadata.annotations or {}).copy()) (metadata.annotations or EMPTY_DICT).copy())
# get synchronization state # get synchronization state
sync = nodes.get(path + self._SYNC) sync = nodes.get(path + self._SYNC)
@@ -1047,8 +1048,9 @@ class Kubernetes(AbstractDCS):
def __target_ref(self, leader_ip: str, latest_subsets: List[K8sObject], pod: K8sObject) -> K8sObject: def __target_ref(self, leader_ip: str, latest_subsets: List[K8sObject], pod: K8sObject) -> K8sObject:
# we want to re-use existing target_ref if possible # we want to re-use existing target_ref if possible
empty_addresses: List[K8sObject] = []
for subset in latest_subsets: for subset in latest_subsets:
for address in subset.addresses or []: for address in subset.addresses or empty_addresses:
if address.ip == leader_ip and address.target_ref and address.target_ref.name == self._name: if address.ip == leader_ip and address.target_ref and address.target_ref.name == self._name:
return address.target_ref return address.target_ref
return k8s_client.V1ObjectReference(kind='Pod', uid=pod.metadata.uid, namespace=self._namespace, return k8s_client.V1ObjectReference(kind='Pod', uid=pod.metadata.uid, namespace=self._namespace,
@@ -1056,7 +1058,8 @@ class Kubernetes(AbstractDCS):
def _map_subsets(self, endpoints: Dict[str, Any], ips: List[str]) -> None: def _map_subsets(self, endpoints: Dict[str, Any], ips: List[str]) -> None:
leader = self._kinds.get(self.leader_path) leader = self._kinds.get(self.leader_path)
latest_subsets = leader and leader.subsets or [] empty_addresses: List[K8sObject] = []
latest_subsets = leader and leader.subsets or empty_addresses
if not ips: if not ips:
# We want to have subsets empty # We want to have subsets empty
if latest_subsets: if latest_subsets:
@@ -1212,7 +1215,7 @@ class Kubernetes(AbstractDCS):
if not retry.ensure_deadline(0.5): if not retry.ensure_deadline(0.5):
return False return False
kind_annotations = kind and kind.metadata.annotations or {} kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
kind_resource_version = kind and kind.metadata.resource_version kind_resource_version = kind and kind.metadata.resource_version
# There is different leader or resource_version in cache didn't change # There is different leader or resource_version in cache didn't change
@@ -1225,7 +1228,7 @@ class Kubernetes(AbstractDCS):
def update_leader(self, leader: Leader, last_lsn: Optional[int], def update_leader(self, leader: Leader, last_lsn: Optional[int],
slots: Optional[Dict[str, int]] = None, failsafe: Optional[Dict[str, str]] = None) -> bool: slots: Optional[Dict[str, int]] = None, failsafe: Optional[Dict[str, str]] = None) -> bool:
kind = self._kinds.get(self.leader_path) kind = self._kinds.get(self.leader_path)
kind_annotations = kind and kind.metadata.annotations or {} kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
if kind and kind_annotations.get(self._LEADER) != self._name: if kind and kind_annotations.get(self._LEADER) != self._name:
return False return False
@@ -1346,7 +1349,7 @@ class Kubernetes(AbstractDCS):
def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool: def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool:
ret = False ret = False
kind = self._kinds.get(self.leader_path) kind = self._kinds.get(self.leader_path)
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name: if kind and (kind.metadata.annotations or EMPTY_DICT).get(self._LEADER) == self._name:
annotations: Dict[str, Optional[str]] = {self._LEADER: None} annotations: Dict[str, Optional[str]] = {self._LEADER: None}
if last_lsn: if last_lsn:
annotations[self._OPTIME] = str(last_lsn) annotations[self._OPTIME] = str(last_lsn)
+3 -2
View File
@@ -10,6 +10,7 @@ import types
from copy import deepcopy from copy import deepcopy
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from .collections import EMPTY_DICT
from .utils import parse_bool, parse_int from .utils import parse_bool, parse_int
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
@@ -214,7 +215,7 @@ class GlobalConfig(types.ModuleType):
@property @property
def use_slots(self) -> bool: def use_slots(self) -> bool:
"""``True`` if cluster is configured to use replication slots.""" """``True`` if cluster is configured to use replication slots."""
return bool(parse_bool((self.get('postgresql') or {}).get('use_slots', True))) return bool(parse_bool((self.get('postgresql') or EMPTY_DICT).get('use_slots', True)))
@property @property
def permanent_slots(self) -> Dict[str, Any]: def permanent_slots(self) -> Dict[str, Any]:
@@ -222,7 +223,7 @@ class GlobalConfig(types.ModuleType):
return deepcopy(self.get('permanent_replication_slots') return deepcopy(self.get('permanent_replication_slots')
or self.get('permanent_slots') or self.get('permanent_slots')
or self.get('slots') or self.get('slots')
or {}) or EMPTY_DICT.copy())
sys.modules[__name__] = GlobalConfig() sys.modules[__name__] = GlobalConfig()
+2 -1
View File
@@ -413,7 +413,8 @@ class PatroniLogger(Thread):
if not isinstance(handler, RotatingFileHandler): if not isinstance(handler, RotatingFileHandler):
handler = RotatingFileHandler(os.path.join(config['dir'], __name__)) handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues] max_file_size = int(config.get('file_size', 25000000))
handler.maxBytes = max_file_size # pyright: ignore [reportAttributeAccessIssue]
handler.backupCount = int(config.get('file_num', 4)) handler.backupCount = int(config.get('file_num', 4))
# we can't use `if not isinstance(handler, logging.StreamHandler)` below, # we can't use `if not isinstance(handler, logging.StreamHandler)` below,
# because RotatingFileHandler is a child of StreamHandler!!! # because RotatingFileHandler is a child of StreamHandler!!!
+3 -3
View File
@@ -26,7 +26,7 @@ from .slots import SlotsHandler
from .sync import SyncHandler from .sync import SyncHandler
from .. import global_config, psycopg from .. import global_config, psycopg
from ..async_executor import CriticalTask from ..async_executor import CriticalTask
from ..collections import CaseInsensitiveSet, CaseInsensitiveDict from ..collections import CaseInsensitiveSet, CaseInsensitiveDict, EMPTY_DICT
from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION
from ..exceptions import PostgresConnectionException from ..exceptions import PostgresConnectionException
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
@@ -272,7 +272,7 @@ class Postgresql(object):
:returns: path to Postgres binary named *cmd*. :returns: path to Postgres binary named *cmd*.
""" """
return os.path.join(self._bin_dir, (self.config.get('bin_name', {}) or {}).get(cmd, cmd)) return os.path.join(self._bin_dir, (self.config.get('bin_name', {}) or EMPTY_DICT).get(cmd, cmd))
def pg_ctl(self, cmd: str, *args: str, **kwargs: Any) -> bool: def pg_ctl(self, cmd: str, *args: str, **kwargs: Any) -> bool:
"""Builds and executes pg_ctl command """Builds and executes pg_ctl command
@@ -414,7 +414,7 @@ class Postgresql(object):
return data_directory_is_empty(self._data_dir) return data_directory_is_empty(self._data_dir)
def replica_method_options(self, method: str) -> Dict[str, Any]: def replica_method_options(self, method: str) -> Dict[str, Any]:
return deepcopy(self.config.get(method, {}) or {}) return deepcopy(self.config.get(method, {}) or EMPTY_DICT.copy())
def replica_method_can_work_without_replication_connection(self, method: str) -> bool: def replica_method_can_work_without_replication_connection(self, method: str) -> bool:
return method != 'basebackup' and bool(self.replica_method_options(method).get('no_master') return method != 'basebackup' and bool(self.replica_method_options(method).get('no_master')
@@ -1,12 +1,13 @@
import logging import logging
import sys import sys
from pathlib import Path
from typing import Iterator from typing import Iterator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if sys.version_info < (3, 9): if sys.version_info < (3, 9): # pragma: no cover
from pathlib import Path
PathLikeObj = Path PathLikeObj = Path
conf_dir = Path(__file__).parent conf_dir = Path(__file__).parent
else: else:
+3 -2
View File
@@ -7,6 +7,7 @@ import time
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from ..async_executor import CriticalTask from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, Member, RemoteMember from ..dcs import Leader, Member, RemoteMember
from ..psycopg import quote_ident, quote_literal from ..psycopg import quote_ident, quote_literal
from ..utils import deep_compare, unquote from ..utils import deep_compare, unquote
@@ -146,7 +147,7 @@ class Bootstrap(object):
# make sure there is no trigger file or postgres will be automatically promoted # make sure there is no trigger file or postgres will be automatically promoted
trigger_file = self._postgresql.config.triggerfile_good_name trigger_file = self._postgresql.config.triggerfile_good_name
trigger_file = (self._postgresql.config.get('recovery_conf') or {}).get(trigger_file) or 'promote' trigger_file = (self._postgresql.config.get('recovery_conf') or EMPTY_DICT).get(trigger_file) or 'promote'
trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file)) trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
if os.path.exists(trigger_file): if os.path.exists(trigger_file):
os.unlink(trigger_file) os.unlink(trigger_file)
@@ -441,7 +442,7 @@ END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
if config.get('users'): if config.get('users'):
logger.warning('User creation via "bootstrap.users" will be removed in v4.0.0') logger.warning('User creation via "bootstrap.users" will be removed in v4.0.0')
for name, value in (config.get('users') or {}).items(): for name, value in (config.get('users') or EMPTY_DICT).items():
if all(name != a.get('username') for a in (superuser, replication, rewind)): if all(name != a.get('username') for a in (superuser, replication, rewind)):
self.create_or_update_role(name, value.get('password'), value.get('options', [])) self.create_or_update_role(name, value.get('password'), value.get('options', []))
+2 -1
View File
@@ -100,7 +100,8 @@ class CancellableSubprocess(CancellableExecutor):
if started and self._process is not None: if started and self._process is not None:
if isinstance(communicate, dict): if isinstance(communicate, dict):
communicate['stdout'], communicate['stderr'] = self._process.communicate(input_data) communicate['stdout'], communicate['stderr'] = \
self._process.communicate(input_data) # pyright: ignore [reportGeneralTypeIssues]
return self._process.wait() return self._process.wait()
finally: finally:
with self._lock: with self._lock:
+5 -4
View File
@@ -13,7 +13,7 @@ from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Un
from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
from .. import global_config from .. import global_config
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet from ..collections import CaseInsensitiveDict, CaseInsensitiveSet, EMPTY_DICT
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
from ..exceptions import PatroniFatalException, PostgresConnectionException from ..exceptions import PatroniFatalException, PostgresConnectionException
from ..file_perm import pg_perm from ..file_perm import pg_perm
@@ -619,7 +619,8 @@ class ConfigHandler(object):
fd.write_param(name, value) fd.write_param(name, value)
def build_recovery_params(self, member: Union[Leader, Member, None]) -> CaseInsensitiveDict: def build_recovery_params(self, member: Union[Leader, Member, None]) -> CaseInsensitiveDict:
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or {}).items() default: Dict[str, Any] = {}
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or default).items()
if not p.lower().startswith('recovery_target') if not p.lower().startswith('recovery_target')
and p.lower() not in ('primary_conninfo', 'primary_slot_name')}) and p.lower() not in ('primary_conninfo', 'primary_slot_name')})
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'}) recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
@@ -845,7 +846,7 @@ class ConfigHandler(object):
required['restart' if mtype else 'reload'] += 1 required['restart' if mtype else 'reload'] += 1
wanted_recovery_params = self.build_recovery_params(member) wanted_recovery_params = self.build_recovery_params(member)
for param, value in (self._current_recovery_params or {}).items(): for param, value in (self._current_recovery_params or EMPTY_DICT).items():
# Skip certain parameters defined in the included postgres config files # Skip certain parameters defined in the included postgres config files
# if we know that they are not specified in the patroni configuration. # if we know that they are not specified in the patroni configuration.
if len(value) > 2 and value[2] not in (self._postgresql_conf, self._auto_conf) and \ if len(value) > 2 and value[2] not in (self._postgresql_conf, self._auto_conf) and \
@@ -1324,4 +1325,4 @@ class ConfigHandler(object):
return self._config.get(key, default) return self._config.get(key, default)
def restore_command(self) -> Optional[str]: def restore_command(self) -> Optional[str]:
return (self.get('recovery_conf') or {}).get('restore_command') return (self.get('recovery_conf') or EMPTY_DICT).get('restore_command')
+2
View File
@@ -299,6 +299,8 @@ def iter_mpp_classes(
:yields: tuples, each containing the module ``name`` and the imported MPP class object. :yields: tuples, each containing the module ``name`` and the imported MPP class object.
""" """
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
yield from iter_classes(__package__, AbstractMPP, config) yield from iter_classes(__package__, AbstractMPP, config)
+402 -88
View File
@@ -4,7 +4,7 @@ import time
from threading import Condition, Event, Thread from threading import Condition, Event, Thread
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Set, Tuple, TYPE_CHECKING
from . import AbstractMPP, AbstractMPPHandler from . import AbstractMPP, AbstractMPPHandler
from ...dcs import Cluster from ...dcs import Cluster
@@ -19,19 +19,312 @@ CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PgDistNode(object): class PgDistNode:
"""Represents a single row in the `pg_dist_node` table""" """Represents a single row in "pg_dist_node" table.
def __init__(self, group: int, host: str, port: int, event: str, nodeid: Optional[int] = None, .. note::
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> None:
self.group = group Unlike "noderole" possible values of ``role`` are ``primary``, ``secondary``, and ``demoted``.
# A weird way of pausing client connections by adding the `-demoted` suffix to the hostname The last one is used to pause client connections on the coordinator to the worker by
self.host = host + ('-demoted' if event == 'before_demote' else '') appending ``-demoted`` suffix to the "nodename". The actual "noderole" in DB remains ``primary``.
:ivar host: "nodename" value
:ivar port: "nodeport" value
:ivar role: "noderole" value
:ivar nodeid: "nodeid" value
"""
def __init__(self, host: str, port: int, role: str, nodeid: Optional[int] = None) -> None:
"""Create a :class:`PgDistNode` object based on given arguments.
:param host: "nodename" of the Citus coordinator or worker.
:param port: "nodeport" of the Citus coordinator or worker.
:param role: "noderole" value.
:param nodeid: id of the row in the "pg_dist_node".
"""
self.host = host
self.port = port self.port = port
self.role = role
self.nodeid = nodeid
def __hash__(self) -> int:
"""Defines a hash function to put :class:`PgDistNode` objects to :class:`PgDistGroup` set-like object.
.. note::
We use (:attr:`host`, :attr:`port`) tuple here because it is one of the UNIQUE constraints on the
"pg_dist_node" table. The :attr:`role` value is irrelevant here because nodes may change their roles.
"""
return hash((self.host, self.port))
def __eq__(self, other: Any) -> bool:
"""Defines a comparison function.
:returns: ``True`` if :attr:`host` and :attr:`port` between two instances are the same.
"""
return isinstance(other, PgDistNode) and self.host == other.host and self.port == other.port
def __str__(self) -> str:
return ('PgDistNode(nodeid={0},host={1},port={2},role={3})'
.format(self.nodeid, self.host, self.port, self.role))
def __repr__(self) -> str:
return str(self)
def is_primary(self) -> bool:
"""Checks whether this object represents "primary" in a corresponding group.
:returns: ``True`` if this object represents the ``primary``.
"""
return self.role in ('primary', 'demoted')
def as_tuple(self, include_nodeid: bool = False) -> Tuple[str, int, str, Optional[int]]:
"""Helper method to compare two :class:`PgDistGroup` objects.
.. note::
*include_nodeid* is set to ``True`` only in unit-tests.
:param include_nodeid: whether :attr:`nodeid` should be taken into account when comparison is performed.
:returns: :class:`tuple` object with :attr:`host`, :attr:`port`, :attr:`role`, and optionally :attr:`nodeid`.
"""
return self.host, self.port, self.role, (self.nodeid if include_nodeid else None)
class PgDistGroup(Set[PgDistNode]):
"""A :class:`set`-like object that represents a Citus group in "pg_dist_node" table.
This class implements a set of methods to compare topology and if it is necessary
to transition from the old to the new topology in a "safe" manner:
* register new primary/secondaries
* replace gone secondaries with added secondaries
* failover and switchover
Typically there will be at least one :class:`PgDistNode` object registered (``primary``).
In addition to that there could be one or more ``secondary`` nodes.
:ivar failover: whether the ``primary`` row should be updated as a result of :func:`transition` method call.
:ivar groupid: the "groupid" from "pg_dist_node".
"""
def __init__(self, groupid: int, nodes: Optional[Collection[PgDistNode]] = None) -> None:
"""Creates a :class:`PgDistGroup` object based on given arguments.
:param groupid: the groupid from "pg_dist_node".
:param nodes: a collection of :class:`PgDistNode` objects that belog to a *groupid*.
"""
self.failover = False
self.groupid = groupid
if nodes:
self.update(nodes)
def equals(self, other: 'PgDistGroup', check_nodeid: bool = False) -> bool:
"""Compares two :class:`PgDistGroup` objects.
:param other: what we want to compare with.
:param check_nodeid: whether :attr:`PgDistNode.nodeid` should be compared in addition to
:attr:`PgDistNode.host`, :attr:`PgDistNode.port`, and :attr:`PgDistNode.role`.
:returns: ``True`` if two :class:`PgDistGroup` objects are fully identical.
"""
return self.groupid == other.groupid\
and set(v.as_tuple(check_nodeid) for v in self) == set(v.as_tuple(check_nodeid) for v in other)
def primary(self) -> Optional[PgDistNode]:
"""Finds and returns :class:`PgDistNode` object that represents the "primary".
:returns: :class:`PgDistNode` object which represents the "primary" or ``None`` if not found.
"""
return next(iter(v for v in self if v.is_primary()), None)
def get(self, value: PgDistNode) -> Optional[PgDistNode]:
"""Performs a lookup of the actual value in a set.
.. note::
It is necessary because :func:`__hash__` and :func:`__eq__` methods in :class:`PgDistNode` are
redefined and effectively they check only :attr:`PgDistNode.host` and :attr:`PgDistNode.port` attributes.
:param value: the key we search for.
:returns: the actual :class:`PgDistNode` value from this :class:`PgDistGroup` object or ``None`` if not found.
"""
return next(iter(v for v in self if v == value), None)
def transition(self, old: 'PgDistGroup') -> Iterator[PgDistNode]:
"""Compares this topology with the old one and yields transitions that transform the old to the new one.
.. note::
The actual yielded object is :class:`PgDistNode` that will be passed to
the :meth:`CitusHandler.update_node` to execute all transitions in a transaction.
In addition to the yielding transactions this method fills up :attr:`PgDistNode.nodeid`
attribute for nodes that are presented in the old and in the new topology.
There are a few simple rules/constraints that are imposed by Citus and must be followed:
- adding/removing nodes is only possible when metadata is synced to all registered "priorities".
- the "primary" row in "pg_dist_node" always keeps the nodeid (unless it is
removed, but it is not supported by Patroni).
- "nodename", "nodeport" must be unique across all rows in the "pg_dist_node". This means that
every time we want to change the nodeid of an existing node (i.e. to change it from secondary
to primary), we should first write some other "nodename"/"nodeport" to the row it's currently in.
- updating "broken" nodes always works and metadata is synced asynchnonously after the commit.
Following these rules below is an example of the switchover between node1 (primary, nodeid=4)
and node2 (secondary, nodeid=5).
.. code-block:: SQL
BEGIN;
SELECT citus_update_node(4, 'node1-demoted', 5432);
SELECT citus_update_node(5, 'node1', 5432);
SELECT citus_update_node(4, 'node2', 5432);
COMMIT;
:param old: the last known topology registered in "pg_dist_node" for a given :attr:`groupid`.
:yields: :class:`PgDistNode` objects that must be updated/added/removed in "pg_dist_node".
"""
self.failover = old.failover
new_primary = self.primary()
assert new_primary is not None
old_primary = old.primary()
gone_nodes = old - self - {old_primary}
added_nodes = self - old - {new_primary}
if not old_primary:
# We did not have any nodes in the group yet and we're adding one now
yield new_primary
elif old_primary == new_primary:
new_primary.nodeid = old_primary.nodeid
# Controlled switchover with pausing client connections.
# Achieved by updating the primary row and putting hostname = '${host}-demoted' in a transaction.
if old_primary.role != new_primary.role:
self.failover = True
yield new_primary
elif old_primary != new_primary:
self.failover = True
new_primary_old_node = old.get(new_primary)
old_primary_new_node = self.get(old_primary)
# The new primary was registered as a secondary before failover
if new_primary_old_node:
new_node = None
# Old primary is gone and some new secondaries were added.
# We can use the row of promoted secondary to add the new secondary.
if not old_primary_new_node and added_nodes:
new_node = added_nodes.pop()
new_node.nodeid = new_primary_old_node.nodeid
yield new_node
# notify _maybe_register_old_primary_as_secondary that the old primary should not be re-registered
old_primary.role = 'secondary'
# In opposite case we need to change the primary record to '${host}-demoted:${port}'
# before we can put its host:port to the row of promoted secondary.
elif old_primary.role == 'primary':
old_primary.role = 'demoted'
yield old_primary
# The old primary is gone and the promoted secondary row wasn't yet used.
if not old_primary_new_node and not new_node:
# We have to "add" the gone primary to the row of promoted secondary because
# nodes could not be removed while the metadata isn't synced.
old_primary_new_node = PgDistNode(old_primary.host, old_primary.port, new_primary_old_node.role)
self.add(old_primary_new_node)
# put the old primary instead of promoted secondary
if old_primary_new_node:
old_primary_new_node.nodeid = new_primary_old_node.nodeid
yield old_primary_new_node
# update the primary record with the new information
new_primary.nodeid = old_primary.nodeid
yield new_primary
# The new primary was never registered as a standby and there are secondaries that have gone away. Since
# nodes can't be removed while metadata isn't synced we have to temporarily "add" the old primary back.
if not new_primary_old_node and gone_nodes:
# We were in the middle of controlled switchover while the primary disappeared.
# If there are any gone nodes that can't be reused for new secondaries we will
# use one of them to temporarily "add" the old primary back as a secondary.
if not old_primary_new_node and old_primary.role == 'demoted' and len(gone_nodes) > len(added_nodes):
old_primary_new_node = PgDistNode(old_primary.host, old_primary.port, 'secondary')
self.add(old_primary_new_node)
# Use one of the gone secondaries to put host:port of the old primary there.
if old_primary_new_node:
old_primary_new_node.nodeid = gone_nodes.pop().nodeid
yield old_primary_new_node
# Fill nodeid for standbys in the new topology from the old ones
old_replicas = {v: v for v in old if not v.is_primary()}
for n in self:
if not n.is_primary() and not n.nodeid and n in old_replicas:
n.nodeid = old_replicas[n].nodeid
# Reuse nodeid's of gone standbys to "add" new standbys
while gone_nodes and added_nodes:
a = added_nodes.pop()
a.nodeid = gone_nodes.pop().nodeid
yield a
# Adding or removing nodes operations are executed on primaries in all Citus groups in 2PC.
# If we know that the primary was updated (self.failover is True) that automatically means that
# adding/removing nodes calls will fail and the whole transaction will be aborted. Therefore
# we discard operations that add/remove secondaries if we know that the primary was just updated.
# The inconsistency will be automatically resolved on the next Patroni heartbeat loop.
# Remove remaining nodes that are gone, but only in case if metadata is in sync (self.failover is False).
for g in gone_nodes:
if not self.failover:
# Remove the node if we expect metadata to be in sync
yield PgDistNode(g.host, g.port, '')
else:
# Otherwise add these nodes to the new topology
self.add(g)
# Add new nodes to the metadata, but only in case if metadata is in sync (self.failover is False).
for a in added_nodes:
if not self.failover:
# Add the node if we expect metadata to be in sync
yield a
else:
# Otherwise remove them from the new topology
self.discard(a)
class PgDistTask(PgDistGroup):
"""A "task" that represents the current or desired state of "pg_dist_node" for a provided *groupid*.
:ivar group: the "groupid" in "pg_dist_node".
:ivar event: an "event" that resulted in creating this task.
possible values: "before_demote", "before_promote", "after_promote".
:ivar timeout: a transaction timeout if the task resulted in starting a transaction.
:ivar cooldown: the cooldown value for ``citus_update_node()`` UDF call.
:ivar deadline: the time in unix seconds when the transaction is allowed to be rolled back.
"""
def __init__(self, groupid: int, nodes: Optional[Collection[PgDistNode]], event: str,
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> None:
"""Create a :class:`PgDistTask` object based on given arguments.
:param groupid: the groupid from "pg_dist_node".
:param nodes: a collection of :class:`PgDistNode` objects that belog to a *groupid*.
:param event: an "event" that resulted in creating this task.
:param timeout: a transaction timeout if the task resulted in starting a transaction.
:param cooldown: the cooldown value for ``citus_update_node()`` UDF call.
"""
super(PgDistTask, self).__init__(groupid, nodes)
# Event that is trying to change or changed the given row. # Event that is trying to change or changed the given row.
# Possible values: before_demote, before_promote, after_promote. # Possible values: before_demote, before_promote, after_promote.
self.event = event self.event = event
self.nodeid = nodeid
# If transaction was started, we need to COMMIT/ROLLBACK before the deadline # If transaction was started, we need to COMMIT/ROLLBACK before the deadline
self.timeout = timeout self.timeout = timeout
@@ -46,25 +339,20 @@ class PgDistNode(object):
self._event = Event() self._event = Event()
def wait(self) -> None: def wait(self) -> None:
"""Wait until this task is processed by a dedicated thread."""
self._event.wait() self._event.wait()
def wakeup(self) -> None: def wakeup(self) -> None:
"""Notify a thread that created a task that it was processed."""
self._event.set() self._event.set()
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
return isinstance(other, PgDistNode) and self.event == other.event\ return isinstance(other, PgDistTask) and self.event == other.event\
and self.host == other.host and self.port == other.port and super(PgDistTask, self).equals(other)
def __ne__(self, other: Any) -> bool: def __ne__(self, other: Any) -> bool:
return not self == other return not self == other
def __str__(self) -> str:
return ('PgDistNode(nodeid={0},group={1},host={2},port={3},event={4})'
.format(self.nodeid, self.group, self.host, self.port, self.event))
def __repr__(self) -> str:
return str(self)
class Citus(AbstractMPP): class Citus(AbstractMPP):
@@ -109,11 +397,11 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
self._connection = postgresql.connection_pool.get( self._connection = postgresql.connection_pool.get(
'citus', {'dbname': config['database'], 'citus', {'dbname': config['database'],
'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'}) 'options': '-c statement_timeout=0 -c idle_in_transaction_session_timeout=0'})
self._pg_dist_node: Dict[int, PgDistNode] = {} # Cache of pg_dist_node: {groupid: PgDistNode()} self._pg_dist_group: Dict[int, PgDistTask] = {} # Cache of pg_dist_node: {groupid: PgDistTask()}
self._tasks: List[PgDistNode] = [] # Requests to change pg_dist_node, every task is a `PgDistNode` self._tasks: List[PgDistTask] = [] # Requests to change pg_dist_group, every task is a `PgDistTask`
self._in_flight: Optional[PgDistNode] = None # Reference to the `PgDistNode` being changed in a transaction self._in_flight: Optional[PgDistTask] = None # Reference to the `PgDistTask` being changed in a transaction
self._schedule_load_pg_dist_node = True # Flag that "pg_dist_node" should be queried from the database self._schedule_load_pg_dist_group = True # Flag that "pg_dist_group" should be queried from the database
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node self._condition = Condition() # protects _pg_dist_group, _tasks, _in_flight, and _schedule_load_pg_dist_group
self.schedule_cache_rebuild() self.schedule_cache_rebuild()
def schedule_cache_rebuild(self) -> None: def schedule_cache_rebuild(self) -> None:
@@ -122,12 +410,12 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
Is called to notify handler that it has to refresh its metadata cache from the database. Is called to notify handler that it has to refresh its metadata cache from the database.
""" """
with self._condition: with self._condition:
self._schedule_load_pg_dist_node = True self._schedule_load_pg_dist_group = True
def on_demote(self) -> None: def on_demote(self) -> None:
with self._condition: with self._condition:
self._pg_dist_node.clear() self._pg_dist_group.clear()
empty_tasks: List[PgDistNode] = [] empty_tasks: List[PgDistTask] = []
self._tasks[:] = empty_tasks self._tasks[:] = empty_tasks
self._in_flight = None self._in_flight = None
@@ -143,22 +431,28 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
self.schedule_cache_rebuild() self.schedule_cache_rebuild()
raise e raise e
def load_pg_dist_node(self) -> bool: def load_pg_dist_group(self) -> bool:
"""Read from the `pg_dist_node` table and put it into the local cache""" """Read from the `pg_dist_node` table and put it into the local cache"""
with self._condition: with self._condition:
if not self._schedule_load_pg_dist_node: if not self._schedule_load_pg_dist_group:
return True return True
self._schedule_load_pg_dist_node = False self._schedule_load_pg_dist_group = False
try: try:
rows = self.query("SELECT nodeid, groupid, nodename, nodeport, noderole" rows = self.query('SELECT groupid, nodename, nodeport, noderole, nodeid FROM pg_catalog.pg_dist_node')
" FROM pg_catalog.pg_dist_node WHERE noderole = 'primary'")
except Exception: except Exception:
return False return False
pg_dist_group: Dict[int, PgDistTask] = {}
for row in rows:
if row[0] not in pg_dist_group:
pg_dist_group[row[0]] = PgDistTask(row[0], nodes=set(), event='after_promote')
pg_dist_group[row[0]].add(PgDistNode(*row[1:]))
with self._condition: with self._condition:
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in rows} self._pg_dist_group = pg_dist_group
return True return True
def sync_meta_data(self, cluster: Cluster) -> None: def sync_meta_data(self, cluster: Cluster) -> None:
@@ -166,7 +460,7 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
We can't always rely on REST API calls from worker nodes in order We can't always rely on REST API calls from worker nodes in order
to maintain `pg_dist_node`, therefore at least once per heartbeat to maintain `pg_dist_node`, therefore at least once per heartbeat
loop we make sure that workes registered in `self._pg_dist_node` loop we make sure that workes registered in `self._pg_dist_group`
cache are matching the cluster view from DCS by creating tasks cache are matching the cluster view from DCS by creating tasks
the same way as it is done from the REST API.""" the same way as it is done from the REST API."""
@@ -177,20 +471,21 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
if not self.is_alive(): if not self.is_alive():
self.start() self.start()
self.add_task('after_promote', CITUS_COORDINATOR_GROUP_ID, self._postgresql.connection_string) self.add_task('after_promote', CITUS_COORDINATOR_GROUP_ID, cluster,
self._postgresql.name, self._postgresql.connection_string)
for group, worker in cluster.workers.items(): for groupid, worker in cluster.workers.items():
leader = worker.leader leader = worker.leader
if leader and leader.conn_url\ if leader and leader.conn_url\
and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running': and leader.data.get('role') in ('master', 'primary') and leader.data.get('state') == 'running':
self.add_task('after_promote', group, leader.conn_url) self.add_task('after_promote', groupid, worker, leader.name, leader.conn_url)
def find_task_by_group(self, group: int) -> Optional[int]: def find_task_by_groupid(self, groupid: int) -> Optional[int]:
for i, task in enumerate(self._tasks): for i, task in enumerate(self._tasks):
if task.group == group: if task.groupid == groupid:
return i return i
def pick_task(self) -> Tuple[Optional[int], Optional[PgDistNode]]: def pick_task(self) -> Tuple[Optional[int], Optional[PgDistTask]]:
"""Returns the tuple(i, task), where `i` - is the task index in the self._tasks list """Returns the tuple(i, task), where `i` - is the task index in the self._tasks list
Tasks are picked by following priorities: Tasks are picked by following priorities:
@@ -198,44 +493,56 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
1. If there is already a transaction in progress, pick a task 1. If there is already a transaction in progress, pick a task
that that will change already affected worker primary. that that will change already affected worker primary.
2. If the coordinator address should be changed - pick a task 2. If the coordinator address should be changed - pick a task
with group=0 (coordinators are always in group 0). with groupid=0 (coordinators are always in groupid 0).
3. Pick a task that is the oldest (first from the self._tasks) 3. Pick a task that is the oldest (first from the self._tasks)
""" """
with self._condition: with self._condition:
if self._in_flight: if self._in_flight:
i = self.find_task_by_group(self._in_flight.group) i = self.find_task_by_groupid(self._in_flight.groupid)
else: else:
while True: while True:
i = self.find_task_by_group(CITUS_COORDINATOR_GROUP_ID) # set_coordinator i = self.find_task_by_groupid(CITUS_COORDINATOR_GROUP_ID) # set_coordinator
if i is None and self._tasks: if i is None and self._tasks:
i = 0 i = 0
if i is None: if i is None:
break break
task = self._tasks[i] task = self._tasks[i]
if task == self._pg_dist_node.get(task.group): if task == self._pg_dist_group.get(task.groupid):
self._tasks.pop(i) # nothing to do because cached version of pg_dist_node already matches self._tasks.pop(i) # nothing to do because cached version of pg_dist_group already matches
else: else:
break break
task = self._tasks[i] if i is not None else None task = self._tasks[i] if i is not None else None
# When tasks are added it could happen that self._pg_dist_node
# wasn't ready (self._schedule_load_pg_dist_node is False)
# and hence the nodeid wasn't filled.
if task and task.group in self._pg_dist_node:
task.nodeid = self._pg_dist_node[task.group].nodeid
return i, task return i, task
def update_node(self, task: PgDistNode) -> None: def update_node(self, groupid: int, node: PgDistNode, cooldown: float = 10000) -> None:
if task.nodeid is not None: if node.role not in ('primary', 'secondary', 'demoted'):
self.query('SELECT pg_catalog.citus_remove_node(%s, %s)', node.host, node.port)
elif node.nodeid is not None:
host = node.host + ('-demoted' if node.role == 'demoted' else '')
self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)', self.query('SELECT pg_catalog.citus_update_node(%s, %s, %s, true, %s)',
task.nodeid, task.host, task.port, task.cooldown) node.nodeid, host, node.port, cooldown)
elif task.event != 'before_demote': elif node.role != 'demoted':
task.nodeid = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default')", node.nodeid = self.query("SELECT pg_catalog.citus_add_node(%s, %s, %s, %s, 'default')",
task.host, task.port, task.group)[0][0] node.host, node.port, groupid, node.role)[0][0]
def process_task(self, task: PgDistNode) -> bool: def update_group(self, task: PgDistTask, transaction: bool) -> None:
"""Updates a single row in `pg_dist_node` table, optionally in a transaction. current_state = self._in_flight\
or self._pg_dist_group.get(task.groupid)\
or PgDistTask(task.groupid, set(), 'after_promote')
transitions = list(task.transition(current_state))
if transitions:
if not transaction and len(transitions) > 1:
self.query('BEGIN')
for node in transitions:
self.update_node(task.groupid, node, task.cooldown)
if not transaction and len(transitions) > 1:
task.failover = False
self.query('COMMIT')
def process_task(self, task: PgDistTask) -> bool:
"""Updates a single row in `pg_dist_group` table, optionally in a transaction.
The transaction is started if we do a demote of the worker node or before promoting the other worker if The transaction is started if we do a demote of the worker node or before promoting the other worker if
there is no transaction in progress. And, the transaction is committed when the switchover/failover completed. there is no transaction in progress. And, the transaction is committed when the switchover/failover completed.
@@ -246,34 +553,30 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
.. note: .. note:
Read access to `self._in_flight` isn't protected because we know it can't be changed outside of our thread. Read access to `self._in_flight` isn't protected because we know it can't be changed outside of our thread.
:param task: reference to a :class:`PgDistNode` object that represents a row to be updated/created. :param task: reference to a :class:`PgDistTask` object that represents a row to be updated/created.
:returns: `True` if the row was succesfully created/updated or transaction in progress :returns: ``True`` if the row was succesfully created/updated or transaction in progress
was committed as an indicator that the `self._pg_dist_node` cache should be updated, was committed as an indicator that the `self._pg_dist_group` cache should be updated,
or, if the new transaction was opened, this method returns `False`. or, if the new transaction was opened, this method returns `False`.
""" """
if task.event == 'after_promote': if task.event == 'after_promote':
# The after_promote may happen without previous before_demote and/or self.update_group(task, self._in_flight is not None)
# before_promore. In this case we just call self.update_node() method.
# If there is a transaction in progress, it could be that it already did
# required changes and we can simply COMMIT.
if not self._in_flight or self._in_flight.host != task.host or self._in_flight.port != task.port:
self.update_node(task)
if self._in_flight: if self._in_flight:
self.query('COMMIT') self.query('COMMIT')
task.failover = False
return True return True
else: # before_demote, before_promote else: # before_demote, before_promote
if task.timeout: if task.timeout:
task.deadline = time.time() + task.timeout task.deadline = time.time() + task.timeout
if not self._in_flight: if not self._in_flight:
self.query('BEGIN') self.query('BEGIN')
self.update_node(task) self.update_group(task, True)
return False return False
def process_tasks(self) -> None: def process_tasks(self) -> None:
while True: while True:
# Read access to `_in_flight` isn't protected because we know it can't be changed outside of our thread. # Read access to `_in_flight` isn't protected because we know it can't be changed outside of our thread.
if not self._in_flight and not self.load_pg_dist_node(): if not self._in_flight and not self.load_pg_dist_group():
break break
i, task = self.pick_task() i, task = self.pick_task()
@@ -287,7 +590,7 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
with self._condition: with self._condition:
if self._tasks: if self._tasks:
if update_cache: if update_cache:
self._pg_dist_node[task.group] = task self._pg_dist_group[task.groupid] = task
if update_cache is False: # an indicator that process_tasks has started a transaction if update_cache is False: # an indicator that process_tasks has started a transaction
self._in_flight = task self._in_flight = task
@@ -302,7 +605,7 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
while True: while True:
try: try:
with self._condition: with self._condition:
if self._schedule_load_pg_dist_node: if self._schedule_load_pg_dist_group:
timeout = -1 timeout = -1
elif self._in_flight: elif self._in_flight:
timeout = self._in_flight.deadline - time.time() if self._tasks else None timeout = self._in_flight.deadline - time.time() if self._tasks else None
@@ -319,9 +622,9 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
except Exception: except Exception:
logger.exception('run') logger.exception('run')
def _add_task(self, task: PgDistNode) -> bool: def _add_task(self, task: PgDistTask) -> bool:
with self._condition: with self._condition:
i = self.find_task_by_group(task.group) i = self.find_task_by_groupid(task.groupid)
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_meta_data(). # The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_meta_data().
if task.timeout is None: if task.timeout is None:
@@ -333,37 +636,48 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
# key is updated in DCS. Therefore it is possible that :func:`sync_meta_data` will try to create a task # key is updated in DCS. Therefore it is possible that :func:`sync_meta_data` will try to create a task
# based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout. # based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
# Only when the timeout is reached new tasks could be scheduled from sync_meta_data() # Only when the timeout is reached new tasks could be scheduled from sync_meta_data()
if self._in_flight and self._in_flight.group == task.group and self._in_flight.timeout is not None\ if self._in_flight and self._in_flight.groupid == task.groupid and self._in_flight.timeout is not None\
and self._in_flight.deadline > time.time(): and self._in_flight.deadline > time.time():
return False return False
# Override already existing task for the same worker group # Override already existing task for the same worker groupid
if i is not None: if i is not None:
if task != self._tasks[i]: if task != self._tasks[i]:
logger.debug('Overriding existing task: %s != %s', self._tasks[i], task) logger.debug('Overriding existing task: %s != %s', self._tasks[i], task)
self._tasks[i] = task self._tasks[i] = task
self._condition.notify() self._condition.notify()
return True return True
# Add the task to the list if Worker node state is different from the cached `pg_dist_node` # Add the task to the list if Worker node state is different from the cached `pg_dist_group`
elif self._schedule_load_pg_dist_node or task != self._pg_dist_node.get(task.group)\ elif self._schedule_load_pg_dist_group or task != self._pg_dist_group.get(task.groupid)\
or self._in_flight and task.group == self._in_flight.group: or self._in_flight and task.groupid == self._in_flight.groupid:
logger.debug('Adding the new task: %s', task) logger.debug('Adding the new task: %s', task)
self._tasks.append(task) self._tasks.append(task)
self._condition.notify() self._condition.notify()
return True return True
return False return False
def add_task(self, event: str, group: int, conn_url: str, @staticmethod
timeout: Optional[float] = None, cooldown: Optional[float] = None) -> Optional[PgDistNode]: def _pg_dist_node(role: str, conn_url: str) -> Optional[PgDistNode]:
try: try:
r = urlparse(conn_url) r = urlparse(conn_url)
if r.hostname:
return PgDistNode(r.hostname, r.port or 5432, role)
except Exception as e: except Exception as e:
return logger.error('Failed to parse connection url %s: %r', conn_url, e) logger.error('Failed to parse connection url %s: %r', conn_url, e)
host = r.hostname
if host: def add_task(self, event: str, groupid: int, cluster: Cluster, leader_name: str, leader_url: str,
port = r.port or 5432 timeout: Optional[float] = None, cooldown: Optional[float] = None) -> Optional[PgDistTask]:
task = PgDistNode(group, host, port, event, timeout=timeout, cooldown=cooldown) primary = self._pg_dist_node('demoted' if event == 'before_demote' else 'primary', leader_url)
return task if self._add_task(task) else None if not primary:
return
task = PgDistTask(groupid, {primary}, event=event, timeout=timeout, cooldown=cooldown)
for member in cluster.members:
secondary = self._pg_dist_node('secondary', member.conn_url)\
if member.name != leader_name and member.is_running and member.conn_url else None
if secondary:
task.add(secondary)
return task if self._add_task(task) else None
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None: def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
if not self.is_alive(): if not self.is_alive():
@@ -371,10 +685,10 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
worker = cluster.workers.get(event['group']) worker = cluster.workers.get(event['group'])
if not (worker and worker.leader and worker.leader.name == event['leader'] and worker.leader.conn_url): if not (worker and worker.leader and worker.leader.name == event['leader'] and worker.leader.conn_url):
return return logger.info('Discarding event %s', event)
task = self.add_task(event['type'], event['group'], task = self.add_task(event['type'], event['group'], worker,
worker.leader.conn_url, worker.leader.name, worker.leader.conn_url,
event['timeout'], event['cooldown'] * 1000) event['timeout'], event['cooldown'] * 1000)
if task and event['type'] == 'before_demote': if task and event['type'] == 'before_demote':
task.wait() task.wait()
+2 -1
View File
@@ -13,6 +13,7 @@ from . import Postgresql
from .connection import get_connection_cursor from .connection import get_connection_cursor
from .misc import format_lsn, fsync_dir, parse_history, parse_lsn from .misc import format_lsn, fsync_dir, parse_history, parse_lsn
from ..async_executor import CriticalTask from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, RemoteMember from ..dcs import Leader, RemoteMember
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -418,7 +419,7 @@ class Rewind(object):
dsn = self._postgresql.config.format_dsn(r, True) dsn = self._postgresql.config.format_dsn(r, True)
logger.info('running pg_rewind from %s', dsn) logger.info('running pg_rewind from %s', dsn)
restore_command = (self._postgresql.config.get('recovery_conf') or {}).get('restore_command') \ restore_command = (self._postgresql.config.get('recovery_conf') or EMPTY_DICT).get('restore_command') \
if self._postgresql.major_version < 120000 else self._postgresql.get_guc_value('restore_command') if self._postgresql.major_version < 120000 else self._postgresql.get_guc_value('restore_command')
# Until v15 pg_rewind expected postgresql.conf to be inside $PGDATA, which is not the case on e.g. Debian # Until v15 pg_rewind expected postgresql.conf to be inside $PGDATA, which is not the case on e.g. Debian
+3 -2
View File
@@ -42,7 +42,8 @@ try:
value.prepare(conn) value.prepare(conn)
return value.getquoted().decode('utf-8') return value.getquoted().decode('utf-8')
except ImportError: except ImportError:
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError from psycopg import connect as __connect # pyright: ignore [reportUnknownVariableType]
from psycopg import sql, Error, DatabaseError, OperationalError, ProgrammingError
def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]': def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]':
"""Call :func:`psycopg.connect` with *dsn* and ``**kwargs``. """Call :func:`psycopg.connect` with *dsn* and ``**kwargs``.
@@ -56,7 +57,7 @@ except ImportError:
:returns: a connection to the database. :returns: a connection to the database.
""" """
ret = __connect(dsn or "", **kwargs) ret: 'Connection[Any]' = __connect(dsn or "", **kwargs)
setattr(ret, 'server_version', ret.pgconn.server_version) # compatibility with psycopg2 setattr(ret, 'server_version', ret.pgconn.server_version) # compatibility with psycopg2
return ret return ret
+2 -3
View File
@@ -11,8 +11,7 @@ import socket
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING
from .collections import CaseInsensitiveSet from .collections import CaseInsensitiveSet, EMPTY_DICT
from .dcs import dcs_modules from .dcs import dcs_modules
from .exceptions import ConfigParseError from .exceptions import ConfigParseError
from .utils import parse_int, split_host_port, data_directory_is_empty, get_major_version from .utils import parse_int, split_host_port, data_directory_is_empty, get_major_version
@@ -245,7 +244,7 @@ def get_bin_name(bin_name: str) -> str:
""" """
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
assert isinstance(schema.data, dict) assert isinstance(schema.data, dict)
return (schema.data.get('postgresql', {}).get('bin_name', {}) or {}).get(bin_name, bin_name) return (schema.data.get('postgresql', {}).get('bin_name', {}) or EMPTY_DICT).get(bin_name, bin_name)
def validate_data_dir(data_dir: str) -> bool: def validate_data_dir(data_dir: str) -> bool:
+6 -2
View File
@@ -179,8 +179,12 @@ class MockCursor(object):
b'3\t0/403DD98\tno recovery target specified\n')] b'3\t0/403DD98\tno recovery target specified\n')]
elif sql.startswith('SELECT pg_catalog.citus_add_node'): elif sql.startswith('SELECT pg_catalog.citus_add_node'):
self.results = [(2,)] self.results = [(2,)]
elif sql.startswith('SELECT nodeid, groupid'): elif sql.startswith('SELECT groupid, nodename'):
self.results = [(1, 0, 'host1', 5432, 'primary'), (2, 1, 'host2', 5432, 'primary')] self.results = [(0, 'host1', 5432, 'primary', 1),
(0, '127.0.0.1', 5436, 'secondary', 2),
(1, 'host4', 5432, 'primary', 3),
(1, '127.0.0.1', 5437, 'secondary', 4),
(1, '127.0.0.1', 5438, 'secondary', 5)]
else: else:
self.results = [(None, None, None, None, None, None, None, None, None, None)] self.results = [(None, None, None, None, None, None, None, None, None, None)]
self.rowcount = len(self.results) self.rowcount = len(self.results)
+248 -28
View File
@@ -1,6 +1,10 @@
import time import time
import unittest
from copy import deepcopy
from mock import Mock, patch, PropertyMock from mock import Mock, patch, PropertyMock
from patroni.postgresql.mpp.citus import CitusHandler from typing import List
from patroni.postgresql.mpp.citus import CitusHandler, PgDistGroup, PgDistNode
from patroni.psycopg import ProgrammingError from patroni.psycopg import ProgrammingError
from . import BaseTestPostgresql, MockCursor, psycopg_connect, SleepException from . import BaseTestPostgresql, MockCursor, psycopg_connect, SleepException
@@ -20,7 +24,7 @@ class TestCitus(BaseTestPostgresql):
@patch('time.time', Mock(side_effect=[100, 130, 160, 190, 220, 250, 280, 310, 340, 370])) @patch('time.time', Mock(side_effect=[100, 130, 160, 190, 220, 250, 280, 310, 340, 370]))
@patch('patroni.postgresql.mpp.citus.logger.exception', Mock(side_effect=SleepException)) @patch('patroni.postgresql.mpp.citus.logger.exception', Mock(side_effect=SleepException))
@patch('patroni.postgresql.mpp.citus.logger.warning') @patch('patroni.postgresql.mpp.citus.logger.warning')
@patch('patroni.postgresql.mpp.citus.PgDistNode.wait', Mock()) @patch('patroni.postgresql.mpp.citus.PgDistTask.wait', Mock())
@patch.object(CitusHandler, 'is_alive', Mock(return_value=True)) @patch.object(CitusHandler, 'is_alive', Mock(return_value=True))
def test_run(self, mock_logger_warning): def test_run(self, mock_logger_warning):
# `before_demote` or `before_promote` REST API calls starting a # `before_demote` or `before_promote` REST API calls starting a
@@ -32,11 +36,11 @@ class TestCitus(BaseTestPostgresql):
self.c.handle_event(self.cluster, {'type': 'before_demote', 'group': 1, self.c.handle_event(self.cluster, {'type': 'before_demote', 'group': 1,
'leader': 'leader', 'timeout': 30, 'cooldown': 10}) 'leader': 'leader', 'timeout': 30, 'cooldown': 10})
self.c.add_task('after_promote', 2, 'postgres://host3:5432/postgres') self.c.add_task('after_promote', 2, self.cluster, self.cluster.leader_name, 'postgres://host3:5432/postgres')
self.assertRaises(SleepException, self.c.run) self.assertRaises(SleepException, self.c.run)
mock_logger_warning.assert_called_once() mock_logger_warning.assert_called_once()
self.assertTrue(mock_logger_warning.call_args[0][0].startswith('Rolling back transaction')) self.assertTrue(mock_logger_warning.call_args[0][0].startswith('Rolling back transaction'))
self.assertTrue(repr(mock_logger_warning.call_args[0][1]).startswith('PgDistNode')) self.assertTrue(repr(mock_logger_warning.call_args[0][1]).startswith('PgDistTask'))
@patch.object(CitusHandler, 'is_alive', Mock(return_value=False)) @patch.object(CitusHandler, 'is_alive', Mock(return_value=False))
@patch.object(CitusHandler, 'start', Mock()) @patch.object(CitusHandler, 'start', Mock())
@@ -54,59 +58,68 @@ class TestCitus(BaseTestPostgresql):
def test_add_task(self): def test_add_task(self):
with patch('patroni.postgresql.mpp.citus.logger.error') as mock_logger, \ with patch('patroni.postgresql.mpp.citus.logger.error') as mock_logger, \
patch('patroni.postgresql.mpp.citus.urlparse', Mock(side_effect=Exception)): patch('patroni.postgresql.mpp.citus.urlparse', Mock(side_effect=Exception)):
self.c.add_task('', 1, None) self.c.add_task('', 1, self.cluster, '', None)
mock_logger.assert_called_once() mock_logger.assert_called_once()
with patch('patroni.postgresql.mpp.citus.logger.debug') as mock_logger: with patch('patroni.postgresql.mpp.citus.logger.debug') as mock_logger:
self.c.add_task('before_demote', 1, 'postgres://host:5432/postgres', 30) self.c.add_task('before_demote', 1, self.cluster,
self.cluster.leader_name, 'postgres://host:5432/postgres', 30)
mock_logger.assert_called_once() mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Adding the new task:')) self.assertTrue(mock_logger.call_args[0][0].startswith('Adding the new task:'))
with patch('patroni.postgresql.mpp.citus.logger.debug') as mock_logger: with patch('patroni.postgresql.mpp.citus.logger.debug') as mock_logger:
self.c.add_task('before_promote', 1, 'postgres://host:5432/postgres', 30) self.c.add_task('before_promote', 1, self.cluster,
self.cluster.leader_name, 'postgres://host:5432/postgres', 30)
mock_logger.assert_called_once() mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Overriding existing task:')) self.assertTrue(mock_logger.call_args[0][0].startswith('Overriding existing task:'))
# add_task called from sync_meta_data should not override already scheduled or in flight task until deadline # add_task called from sync_pg_dist_node should not override already scheduled or in flight task until deadline
self.assertIsNotNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres', 30)) self.assertIsNotNone(self.c.add_task('after_promote', 1, self.cluster,
self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres')) self.cluster.leader_name, 'postgres://host:5432/postgres', 30))
self.assertIsNone(self.c.add_task('after_promote', 1, self.cluster,
self.cluster.leader_name, 'postgres://host:5432/postgres'))
self.c._in_flight = self.c._tasks.pop() self.c._in_flight = self.c._tasks.pop()
self.c._in_flight.deadline = self.c._in_flight.timeout + time.time() self.c._in_flight.deadline = self.c._in_flight.timeout + time.time()
self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres')) self.assertIsNone(self.c.add_task('after_promote', 1, self.cluster,
self.cluster.leader_name, 'postgres://host:5432/postgres'))
self.c._in_flight.deadline = 0 self.c._in_flight.deadline = 0
self.assertIsNotNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres')) self.assertIsNotNone(self.c.add_task('after_promote', 1, self.cluster,
self.cluster.leader_name, 'postgres://host:5432/postgres'))
# If there is no transaction in progress and cached pg_dist_node matching desired state task should not be added # If there is no transaction in progress and cached pg_dist_node matching desired state task should not be added
self.c._schedule_load_pg_dist_node = False self.c._schedule_load_pg_dist_node = False
self.c._pg_dist_node[self.c._in_flight.group] = self.c._in_flight self.c._pg_dist_group[self.c._in_flight.groupid] = self.c._in_flight
self.c._in_flight = None self.c._in_flight = None
self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres')) self.assertIsNone(self.c.add_task('after_promote', 1, self.cluster,
self.cluster.leader_name, 'postgres://host:5432/postgres'))
def test_pick_task(self): def test_pick_task(self):
self.c.add_task('after_promote', 1, 'postgres://host2:5432/postgres') self.c.add_task('after_promote', 0, self.cluster, self.cluster.leader_name, 'postgres://host1:5432/postgres')
with patch.object(CitusHandler, 'process_task') as mock_process_task: with patch.object(CitusHandler, 'update_node') as mock_update_node:
self.c.process_tasks() self.c.process_tasks()
# process_task() shouln't be called because pick_task double checks with _pg_dist_node # process_task() shouln't be called because pick_task double checks with _pg_dist_group
mock_process_task.assert_not_called() mock_update_node.assert_not_called()
def test_process_task(self): def test_process_task(self):
self.c.add_task('after_promote', 0, 'postgres://host2:5432/postgres') self.c.add_task('after_promote', 1, self.cluster, self.cluster.leader_name, 'postgres://host2:5432/postgres')
task = self.c.add_task('before_promote', 1, 'postgres://host4:5432/postgres', 30) task = self.c.add_task('before_promote', 1, self.cluster,
self.cluster.leader_name, 'postgres://host4:5432/postgres', 30)
self.c.process_tasks() self.c.process_tasks()
self.assertTrue(task._event.is_set()) self.assertTrue(task._event.is_set())
# the after_promote should result only in COMMIT # the after_promote should result only in COMMIT
task = self.c.add_task('after_promote', 1, 'postgres://host4:5432/postgres', 30) task = self.c.add_task('after_promote', 1, self.cluster,
self.cluster.leader_name, 'postgres://host4:5432/postgres', 30)
with patch.object(CitusHandler, 'query') as mock_query: with patch.object(CitusHandler, 'query') as mock_query:
self.c.process_tasks() self.c.process_tasks()
mock_query.assert_called_once() mock_query.assert_called_once()
self.assertEqual(mock_query.call_args[0][0], 'COMMIT') self.assertEqual(mock_query.call_args[0][0], 'COMMIT')
def test_process_tasks(self): def test_process_tasks(self):
self.c.add_task('after_promote', 0, 'postgres://host2:5432/postgres') self.c.add_task('after_promote', 0, self.cluster, self.cluster.leader_name, 'postgres://host2:5432/postgres')
self.c.process_tasks() self.c.process_tasks()
self.c.add_task('after_promote', 0, 'postgres://host3:5432/postgres') self.c.add_task('after_promote', 0, self.cluster, self.cluster.leader_name, 'postgres://host3:5432/postgres')
with patch('patroni.postgresql.mpp.citus.logger.error') as mock_logger, \ with patch('patroni.postgresql.mpp.citus.logger.error') as mock_logger, \
patch.object(CitusHandler, 'query', Mock(side_effect=Exception)): patch.object(CitusHandler, 'query', Mock(side_effect=Exception)):
self.c.process_tasks() self.c.process_tasks()
@@ -118,16 +131,17 @@ class TestCitus(BaseTestPostgresql):
@patch('patroni.postgresql.mpp.citus.logger.error') @patch('patroni.postgresql.mpp.citus.logger.error')
@patch.object(MockCursor, 'execute', Mock(side_effect=Exception)) @patch.object(MockCursor, 'execute', Mock(side_effect=Exception))
def test_load_pg_dist_node(self, mock_logger): def test_load_pg_dist_group(self, mock_logger):
# load_pg_dist_node() triggers, query fails and exception is property handled # load_pg_dist_group) triggers, query fails and exception is property handled
self.c.process_tasks() self.c.process_tasks()
self.assertTrue(self.c._schedule_load_pg_dist_node) self.assertTrue(self.c._schedule_load_pg_dist_group)
mock_logger.assert_called_once() mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Exception when executing query')) self.assertTrue(mock_logger.call_args[0][0].startswith('Exception when executing query'))
self.assertTrue(mock_logger.call_args[0][1].startswith('SELECT nodeid, groupid, ')) self.assertTrue(mock_logger.call_args[0][1].startswith('SELECT groupid, nodename, '))
def test_wait(self): def test_wait(self):
task = self.c.add_task('before_demote', 1, 'postgres://host:5432/postgres', 30) task = self.c.add_task('before_demote', 1, self.cluster,
self.cluster.leader_name, u'postgres://host:5432/postgres', 30)
task._event.wait = Mock() task._event.wait = Mock()
task.wait() task.wait()
@@ -171,3 +185,209 @@ class TestCitus(BaseTestPostgresql):
self.c.bootstrap() self.c.bootstrap()
mock_logger.assert_called_once() mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Exception when creating database')) self.assertTrue(mock_logger.call_args[0][0].startswith('Exception when creating database'))
class TestGroupTransition(unittest.TestCase):
nodeid = 100
def map_to_sql(self, group: int, transition: PgDistNode) -> str:
if transition.role not in ('primary', 'demoted', 'secondary'):
return "citus_remove_node('{0}', {1})".format(transition.host, transition.port)
elif transition.nodeid:
host = transition.host + ('-demoted' if transition.role == 'demoted' else '')
return "citus_update_node({0}, '{1}', {2})".format(transition.nodeid, host, transition.port)
else:
transition.nodeid = self.nodeid
self.nodeid += 1
return "citus_add_node('{0}', {1}, {2}, '{3}')".format(transition.host, transition.port,
group, transition.role)
def check_transitions(self, old_topology: PgDistGroup, new_topology: PgDistGroup,
expected_transitions: List[str]) -> None:
check_topology = deepcopy(old_topology)
transitions: List[str] = []
for node in new_topology.transition(old_topology):
self.assertTrue(node not in check_topology or (check_topology.get(node) or node).role == 'demoted')
old_node = node.nodeid and next(iter(v for v in check_topology if v.nodeid == node.nodeid), None)
if old_node:
check_topology.discard(old_node)
transitions.append(self.map_to_sql(new_topology.groupid, node))
check_topology.add(node)
self.assertEqual(transitions, expected_transitions)
def test_new_topology(self):
old = PgDistGroup(0)
new = PgDistGroup(0, {PgDistNode('1', 5432, 'primary'),
PgDistNode('2', 5432, 'secondary')})
expected = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=100),
PgDistNode('2', 5432, 'secondary', nodeid=101)})
self.check_transitions(old, new,
["citus_add_node('1', 5432, 0, 'primary')",
"citus_add_node('2', 5432, 0, 'secondary')"])
self.assertTrue(new.equals(expected, True))
def test_switchover(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('1', 5432, 'secondary'),
PgDistNode('2', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('2', 5432, 'primary', nodeid=1),
PgDistNode('1', 5432, 'secondary', nodeid=2)})
self.check_transitions(old, new,
["citus_update_node(1, '1-demoted', 5432)",
"citus_update_node(2, '1', 5432)",
"citus_update_node(1, '2', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_failover(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('2', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('2', 5432, 'primary', nodeid=1),
PgDistNode('1', 5432, 'secondary', nodeid=2)})
self.check_transitions(old, new,
["citus_update_node(1, '1-demoted', 5432)",
"citus_update_node(2, '1', 5432)",
"citus_update_node(1, '2', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_failover_and_new_secondary(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('2', 5432, 'primary'),
PgDistNode('3', 5432, 'secondary')})
expected = PgDistGroup(0, {PgDistNode('2', 5432, 'primary', nodeid=1),
PgDistNode('3', 5432, 'secondary', nodeid=2)})
# the secondary record is used to add the new standby and primary record is updated with the new hostname
self.check_transitions(old, new, ["citus_update_node(2, '3', 5432)", "citus_update_node(1, '2', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_switchover_and_new_secondary_primary_gone(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'demoted', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('2', 5432, 'primary'),
PgDistNode('3', 5432, 'secondary')})
expected = PgDistGroup(0, {PgDistNode('2', 5432, 'primary', nodeid=1),
PgDistNode('3', 5432, 'secondary', nodeid=2)})
# the secondary record is used to add the new standby and primary record is updated with the new hostname
self.check_transitions(old, new, ["citus_update_node(2, '3', 5432)", "citus_update_node(1, '2', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_secondary_replaced(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('1', 5432, 'primary'),
PgDistNode('3', 5432, 'secondary')})
expected = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('3', 5432, 'secondary', nodeid=2)})
self.check_transitions(old, new, ["citus_update_node(2, '3', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_secondary_repmoved(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2),
PgDistNode('3', 5432, 'secondary', nodeid=3)})
new = PgDistGroup(0, {PgDistNode('1', 5432, 'primary'),
PgDistNode('3', 5432, 'secondary')})
expected = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('3', 5432, 'secondary', nodeid=3)})
self.check_transitions(old, new, ["citus_remove_node('2', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_switchover_and_secondary_removed(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2),
PgDistNode('3', 5432, 'secondary', nodeid=3)})
new = PgDistGroup(0, {PgDistNode('1', 5432, 'secondary'),
PgDistNode('2', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('1', 5432, 'secondary', nodeid=2),
PgDistNode('2', 5432, 'primary', nodeid=1),
PgDistNode('3', 5432, 'secondary', nodeid=3)})
self.check_transitions(old, new,
["citus_update_node(1, '1-demoted', 5432)",
"citus_update_node(2, '1', 5432)",
"citus_update_node(1, '2', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_switchover_and_new_secondary(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('1', 5432, 'secondary'),
PgDistNode('2', 5432, 'primary'),
PgDistNode('3', 5432, 'secondary')})
expected = PgDistGroup(0, {PgDistNode('1', 5432, 'secondary', nodeid=2),
PgDistNode('2', 5432, 'primary', nodeid=1)})
self.check_transitions(old, new,
["citus_update_node(1, '1-demoted', 5432)",
"citus_update_node(2, '1', 5432)",
"citus_update_node(1, '2', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_failover_to_new_node_secondary_remains(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('2', 5432, 'secondary'),
PgDistNode('3', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('3', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
self.check_transitions(old, new, ["citus_update_node(1, '3', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_failover_to_new_node_secondary_removed(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('3', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('3', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
# the secondary record needs to be removed before we update the primary record
self.check_transitions(old, new, ["citus_update_node(1, '3', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_switchover_to_new_node_and_secondary_removed(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('1', 5432, 'secondary'),
PgDistNode('3', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('3', 5432, 'primary', nodeid=1),
PgDistNode('1', 5432, 'secondary', nodeid=2)})
self.check_transitions(old, new, ["citus_update_node(1, '3', 5432)", "citus_update_node(2, '1', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_switchover_with_pause(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'primary', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('1', 5432, 'demoted')})
expected = PgDistGroup(0, {PgDistNode('1', 5432, 'demoted', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
self.check_transitions(old, new, ["citus_update_node(1, '1-demoted', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_switchover_after_paused_connections(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'demoted', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('2', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('1', 5432, 'secondary', nodeid=2),
PgDistNode('2', 5432, 'primary', nodeid=1)})
self.check_transitions(old, new, ["citus_update_node(2, '1', 5432)", "citus_update_node(1, '2', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_switchover_to_new_node_after_paused_connections(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'demoted', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('3', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('1', 5432, 'secondary', nodeid=2),
PgDistNode('3', 5432, 'primary', nodeid=1)})
self.check_transitions(old, new, ["citus_update_node(1, '3', 5432)", "citus_update_node(2, '1', 5432)"])
self.assertTrue(new.equals(expected, True))
def test_switchover_to_new_node_after_paused_connections_secondary_added(self):
old = PgDistGroup(0, {PgDistNode('1', 5432, 'demoted', nodeid=1),
PgDistNode('2', 5432, 'secondary', nodeid=2)})
new = PgDistGroup(0, {PgDistNode('4', 5432, 'secondary'),
PgDistNode('3', 5432, 'primary')})
expected = PgDistGroup(0, {PgDistNode('4', 5432, 'secondary', nodeid=2),
PgDistNode('3', 5432, 'primary', nodeid=1)})
self.check_transitions(old, new, ["citus_update_node(1, '3', 5432)", "citus_update_node(2, '4', 5432)"])
self.assertTrue(new.equals(expected, True))
+3 -5
View File
@@ -160,12 +160,10 @@ class TestConfig(unittest.TestCase):
@patch('patroni.config.logger') @patch('patroni.config.logger')
def test__validate_failover_tags(self, mock_logger, mock_get): def test__validate_failover_tags(self, mock_logger, mock_get):
"""Ensures that only one of `nofailover` or `failover_priority` can be provided""" """Ensures that only one of `nofailover` or `failover_priority` can be provided"""
config = Config("postgres0.yml")
# Providing one of `nofailover` or `failover_priority` is fine # Providing one of `nofailover` or `failover_priority` is fine
for single_param in ({"nofailover": True}, {"failover_priority": 1}, {"failover_priority": 0}): for single_param in ({"nofailover": True}, {"failover_priority": 1}, {"failover_priority": 0}):
mock_get.side_effect = [single_param] * 2 mock_get.side_effect = [single_param] * 2
self.assertIsNone(config._validate_failover_tags()) self.assertIsNone(self.config._validate_failover_tags())
mock_logger.warning.assert_not_called() mock_logger.warning.assert_not_called()
# Providing both `nofailover` and `failover_priority` is fine if consistent # Providing both `nofailover` and `failover_priority` is fine if consistent
@@ -175,7 +173,7 @@ class TestConfig(unittest.TestCase):
{"nofailover": "False", "failover_priority": 0} {"nofailover": "False", "failover_priority": 0}
): ):
mock_get.side_effect = [consistent_state] * 2 mock_get.side_effect = [consistent_state] * 2
self.assertIsNone(config._validate_failover_tags()) self.assertIsNone(self.config._validate_failover_tags())
mock_logger.warning.assert_not_called() mock_logger.warning.assert_not_called()
# Providing both inconsistently should log a warning # Providing both inconsistently should log a warning
@@ -186,7 +184,7 @@ class TestConfig(unittest.TestCase):
{"nofailover": "", "failover_priority": 0} {"nofailover": "", "failover_priority": 0}
): ):
mock_get.side_effect = [inconsistent_state] * 2 mock_get.side_effect = [inconsistent_state] * 2
self.assertIsNone(config._validate_failover_tags()) self.assertIsNone(self.config._validate_failover_tags())
mock_logger.warning.assert_called_once_with( mock_logger.warning.assert_called_once_with(
'Conflicting configuration between nofailover: %s and failover_priority: %s.' 'Conflicting configuration between nofailover: %s and failover_priority: %s.'
+ ' Defaulting to nofailover: %s', + ' Defaulting to nofailover: %s',
+5 -1
View File
@@ -80,7 +80,7 @@ def mock_namespaced_kind(*args, **kwargs):
def mock_load_k8s_config(self, *args, **kwargs): def mock_load_k8s_config(self, *args, **kwargs):
self._server = '' self._server = 'http://localhost'
class TestK8sConfig(unittest.TestCase): class TestK8sConfig(unittest.TestCase):
@@ -242,6 +242,7 @@ class BaseTestKubernetes(unittest.TestCase):
self.k.get_cluster() self.k.get_cluster()
@patch('urllib3.PoolManager.request', Mock())
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', mock_namespaced_kind, create=True) @patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', mock_namespaced_kind, create=True)
class TestKubernetesConfigMaps(BaseTestKubernetes): class TestKubernetesConfigMaps(BaseTestKubernetes):
@@ -374,6 +375,7 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
mock_warning.assert_called_once() mock_warning.assert_called_once()
@patch('urllib3.PoolManager.request', Mock())
class TestKubernetesEndpointsNoPodIP(BaseTestKubernetes): class TestKubernetesEndpointsNoPodIP(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True) @patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True)
def setUp(self, config=None): def setUp(self, config=None):
@@ -388,6 +390,7 @@ class TestKubernetesEndpointsNoPodIP(BaseTestKubernetes):
self.assertEqual(args[2].subsets[0].addresses[0].ip, '10.0.0.1') self.assertEqual(args[2].subsets[0].addresses[0].ip, '10.0.0.1')
@patch('urllib3.PoolManager.request', Mock())
class TestKubernetesEndpoints(BaseTestKubernetes): class TestKubernetesEndpoints(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True) @patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True)
@@ -478,6 +481,7 @@ def mock_watch(*args):
return urllib3.HTTPResponse() return urllib3.HTTPResponse()
@patch('urllib3.PoolManager.request', Mock())
class TestCacheBuilder(BaseTestKubernetes): class TestCacheBuilder(BaseTestKubernetes):
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True) @patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)