mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-25 14:53:37 +00:00
Feature: failover priority (#2780)
The priority is configured with `failover_priority` tag. Possible values are from `0` till infinity, where `0` means that the node will never become the leader, which is the same as `nofailover` tag set to `true`. As a result, in the configuration file one should set only one of `failover_priority` or `nofailover` tags. The failover priority kicks in only when there are more than one node have the same receive/replay LSN and are ahead of other nodes in the cluster. In this case the node with higher value of `failover_priority` is preferred. If there is a node with higher values of receive/replay LSN, it will become the new leader even if it has lower value of `failover_priority` (except when priority is set to 0). Close https://github.com/zalando/patroni/issues/2759
This commit is contained in:
@@ -368,11 +368,15 @@ Watchdog
|
|||||||
|
|
||||||
Tags
|
Tags
|
||||||
----
|
----
|
||||||
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
|
|
||||||
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
|
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
|
||||||
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
|
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
|
||||||
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
|
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
|
||||||
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
||||||
|
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races.
|
||||||
|
- **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1.
|
||||||
|
|
||||||
In addition to these predefined tags, you can also add your own ones:
|
In addition to these predefined tags, you can also add your own ones:
|
||||||
|
|
||||||
|
|||||||
@@ -82,4 +82,4 @@ Feature: basic replication
|
|||||||
@reject-duplicate-name
|
@reject-duplicate-name
|
||||||
Scenario: check graceful rejection when two nodes have the same name
|
Scenario: check graceful rejection when two nodes have the same name
|
||||||
Given I start duplicate postgres0 on port 8011
|
Given I start duplicate postgres0 on port 8011
|
||||||
Then there is a "Can't start; there is already a node named 'postgres0' running" CRITICAL in the dup-postgres0 patroni log
|
Then there is one of ["Can't start; there is already a node named 'postgres0' running"] CRITICAL in the dup-postgres0 patroni log after 5 seconds
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
Feature: priority replication
|
||||||
|
We should check that we can give nodes priority during failover
|
||||||
|
|
||||||
|
Scenario: check failover priority 0 prevents leaderships
|
||||||
|
Given I configure and start postgres0 with a tag failover_priority 1
|
||||||
|
And I configure and start postgres1 with a tag failover_priority 0
|
||||||
|
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||||
|
When I shut down postgres0
|
||||||
|
And I sleep for 5 seconds
|
||||||
|
Then postgres1 role is the secondary after 10 seconds
|
||||||
|
And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres1 patroni log after 5 seconds
|
||||||
|
Given I start postgres0
|
||||||
|
Then postgres0 role is the primary after 10 seconds
|
||||||
|
|
||||||
|
Scenario: check higher failover priority is respected
|
||||||
|
Given I configure and start postgres2 with a tag failover_priority 1
|
||||||
|
And I configure and start postgres3 with a tag failover_priority 2
|
||||||
|
Then replication works from postgres0 to postgres2 after 20 seconds
|
||||||
|
And replication works from postgres0 to postgres3 after 20 seconds
|
||||||
|
When I shut down postgres0
|
||||||
|
And I sleep for 5 seconds
|
||||||
|
Then postgres3 role is the primary after 10 seconds
|
||||||
|
And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import patroni.psycopg as pg
|
import patroni.psycopg as pg
|
||||||
|
|
||||||
from behave import step, then
|
from behave import step, then
|
||||||
@@ -113,8 +114,15 @@ def replication_works(context, primary, replica, time_limit):
|
|||||||
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
|
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
|
||||||
|
|
||||||
|
|
||||||
@then('there is a "{message}" {level:w} in the {node} patroni log')
|
@then('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
|
||||||
def check_patroni_log(context, message, level, node):
|
def check_patroni_log(context, message_list, level, node, timeout):
|
||||||
messsages_of_level = context.pctl.read_patroni_log(node, level)
|
timeout *= context.timeout_multiplier
|
||||||
assert any(message in line for line in messsages_of_level), \
|
message_list = json.loads(message_list)
|
||||||
"There was no {0} {1} in the {2} patroni log".format(message, level, node)
|
|
||||||
|
for _ in range(int(timeout)):
|
||||||
|
messsages_of_level = context.pctl.read_patroni_log(node, level)
|
||||||
|
if any(any(message in line for line in messsages_of_level) for message in message_list):
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds"
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ class Config(object):
|
|||||||
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
|
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
|
||||||
self._load_cache()
|
self._load_cache()
|
||||||
self._cache_needs_saving = False
|
self._cache_needs_saving = False
|
||||||
|
self._validate_failover_tags()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config_file(self) -> Optional[str]:
|
def config_file(self) -> Optional[str]:
|
||||||
@@ -959,3 +960,24 @@ class Config(object):
|
|||||||
:returns: :class:`GlobalConfig` object.
|
:returns: :class:`GlobalConfig` object.
|
||||||
"""
|
"""
|
||||||
return get_global_config(cluster, self._dynamic_configuration)
|
return get_global_config(cluster, self._dynamic_configuration)
|
||||||
|
|
||||||
|
def _validate_failover_tags(self) -> None:
|
||||||
|
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
To preserve sanity (and backwards compatibility) the ``nofailover`` tag will still exist. A contradictory
|
||||||
|
configuration is one where ``nofailover`` is ``True`` but ``failover_priority > 0``, or where
|
||||||
|
``nofailover`` is ``False``, but ``failover_priority <= 0``. Essentially, ``nofailover`` and
|
||||||
|
``failover_priority`` are communicating different things.
|
||||||
|
This checks for this edge case (which is a misconfiguration on the part of the user) and warns them.
|
||||||
|
The behaviour is as if ``failover_priority`` were not provided (i.e ``nofailover`` is the
|
||||||
|
bedrock source of truth)
|
||||||
|
"""
|
||||||
|
tags = self.get('tags', {})
|
||||||
|
nofailover_tag = tags.get('nofailover')
|
||||||
|
failover_priority_tag = parse_int(tags.get('failover_priority'))
|
||||||
|
if failover_priority_tag is not None \
|
||||||
|
and (nofailover_tag is True and failover_priority_tag > 0
|
||||||
|
or nofailover_tag is False and failover_priority_tag <= 0):
|
||||||
|
logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. '
|
||||||
|
'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag)
|
||||||
|
|||||||
@@ -1009,6 +1009,15 @@ class Ha(object):
|
|||||||
if not self.sync_mode_is_active() or not self.cluster.sync.leader_matches(st.member.name):
|
if not self.sync_mode_is_active() or not self.cluster.sync.leader_matches(st.member.name):
|
||||||
return False
|
return False
|
||||||
logger.info('Ignoring the former leader being ahead of us')
|
logger.info('Ignoring the former leader being ahead of us')
|
||||||
|
if my_wal_position == st.wal_position and self.patroni.failover_priority < st.failover_priority:
|
||||||
|
# There's a higher priority non-lagging replica
|
||||||
|
logger.info(
|
||||||
|
'%s has equally tolerable WAL position and priority %s, while this node has priority %s',
|
||||||
|
st.member.name,
|
||||||
|
st.failover_priority,
|
||||||
|
self.patroni.failover_priority,
|
||||||
|
)
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_failover_possible(self, *, cluster_lsn: int = 0, exclude_failover_candidate: bool = False) -> bool:
|
def is_failover_possible(self, *, cluster_lsn: int = 0, exclude_failover_candidate: bool = False) -> bool:
|
||||||
|
|||||||
+25
-2
@@ -3,6 +3,8 @@ import abc
|
|||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from patroni.utils import parse_int
|
||||||
|
|
||||||
|
|
||||||
class Tags(abc.ABC):
|
class Tags(abc.ABC):
|
||||||
"""An abstract class that encapsulates all the ``tags`` logic.
|
"""An abstract class that encapsulates all the ``tags`` logic.
|
||||||
@@ -45,8 +47,29 @@ class Tags(abc.ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def nofailover(self) -> bool:
|
def nofailover(self) -> bool:
|
||||||
"""``True`` if ``nofailover`` is ``True``, else ``False``."""
|
"""Common logic for obtaining the value of ``nofailover`` from ``tags`` if defined.
|
||||||
return bool(self.tags.get('nofailover', False))
|
|
||||||
|
If ``nofailover`` is not defined, this methods returns ``True`` if ``failover_priority`` is non-positive,
|
||||||
|
``False`` otherwise.
|
||||||
|
"""
|
||||||
|
from_tags = self.tags.get('nofailover')
|
||||||
|
if from_tags is not None:
|
||||||
|
# Value of `nofailover` takes precedence over `failover_priority`
|
||||||
|
return bool(from_tags)
|
||||||
|
failover_priority = parse_int(self.tags.get('failover_priority'))
|
||||||
|
return failover_priority is not None and failover_priority <= 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failover_priority(self) -> int:
|
||||||
|
"""Common logic for obtaining the value of ``failover_priority`` from ``tags`` if defined.
|
||||||
|
|
||||||
|
If ``nofailover`` is defined as ``True``, this will return ``0``. Otherwise, it will return the value of
|
||||||
|
``failover_priority``, defaulting to ``1`` if it's not defined or invalid.
|
||||||
|
"""
|
||||||
|
from_tags = self.tags.get('nofailover')
|
||||||
|
failover_priority = parse_int(self.tags.get('failover_priority'))
|
||||||
|
failover_priority = 1 if failover_priority is None else failover_priority
|
||||||
|
return 0 if from_tags else failover_priority
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def noloadbalance(self) -> bool:
|
def noloadbalance(self) -> bool:
|
||||||
|
|||||||
+54
-9
@@ -379,6 +379,37 @@ class Or(object):
|
|||||||
self.args = args
|
self.args = args
|
||||||
|
|
||||||
|
|
||||||
|
class AtMostOne(object):
|
||||||
|
"""Mark that at most one option from a :class:`Case` can be suplied.
|
||||||
|
|
||||||
|
Represents a list of possible configuration options in a given scope, where at most one can actually
|
||||||
|
be provided.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
It should be used together with a :class:`Case` object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args: str) -> None:
|
||||||
|
"""Create a :class`AtMostOne` object.
|
||||||
|
|
||||||
|
:param `*args`: any arguments that the caller wants to be stored in this :class:`Or` object.
|
||||||
|
|
||||||
|
:Example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
AtMostOne("nofailover", "failover_priority"): Case({
|
||||||
|
"nofailover": bool,
|
||||||
|
"failover_priority": IntValidator(min=0, raise_assert=True),
|
||||||
|
})
|
||||||
|
|
||||||
|
The :class`AtMostOne` object is used to define that at most one of ``nofailover`` and
|
||||||
|
``failover_priority`` can be provided.
|
||||||
|
"""
|
||||||
|
self.args = args
|
||||||
|
|
||||||
|
|
||||||
class Optional(object):
|
class Optional(object):
|
||||||
"""Mark a configuration option as optional.
|
"""Mark a configuration option as optional.
|
||||||
|
|
||||||
@@ -671,6 +702,9 @@ class Schema(object):
|
|||||||
# One key in `validator` attribute (`key` variable) can be mapped to one or more keys in `data` attribute (`d`
|
# One key in `validator` attribute (`key` variable) can be mapped to one or more keys in `data` attribute (`d`
|
||||||
# variable), depending on the `key` type.
|
# variable), depending on the `key` type.
|
||||||
for key in self.validator.keys():
|
for key in self.validator.keys():
|
||||||
|
if isinstance(key, AtMostOne) and len(list(self._data_key(key))) > 1:
|
||||||
|
yield Result(False, f"Multiple of {key.args} provided")
|
||||||
|
continue
|
||||||
for d in self._data_key(key):
|
for d in self._data_key(key):
|
||||||
if d not in self.data and not isinstance(key, Optional):
|
if d not in self.data and not isinstance(key, Optional):
|
||||||
yield Result(False, "is not defined.", path=d)
|
yield Result(False, "is not defined.", path=d)
|
||||||
@@ -680,7 +714,7 @@ class Schema(object):
|
|||||||
if d not in self.data and isinstance(key, Optional):
|
if d not in self.data and isinstance(key, Optional):
|
||||||
self.data[d] = key.default
|
self.data[d] = key.default
|
||||||
validator = self.validator[key]
|
validator = self.validator[key]
|
||||||
if isinstance(key, Or) and isinstance(self.validator[key], Case):
|
if isinstance(key, (Or, AtMostOne)) and isinstance(self.validator[key], Case):
|
||||||
validator = self.validator[key]._schema[d]
|
validator = self.validator[key]._schema[d]
|
||||||
# In this loop we may be calling a new `Schema` either over an intermediate node in the tree, or
|
# In this loop we may be calling a new `Schema` either over an intermediate node in the tree, or
|
||||||
# over a leaf node. In the latter case the recursive calls in the given path will finish.
|
# over a leaf node. In the latter case the recursive calls in the given path will finish.
|
||||||
@@ -715,7 +749,7 @@ class Schema(object):
|
|||||||
max_level = v.level
|
max_level = v.level
|
||||||
yield Result(v.status, v.error, path=v.path, level=v.level, data=v.data)
|
yield Result(v.status, v.error, path=v.path, level=v.level, data=v.data)
|
||||||
|
|
||||||
def _data_key(self, key: Union[str, Optional, Or]) -> Iterator[str]:
|
def _data_key(self, key: Union[str, Optional, Or, AtMostOne]) -> Iterator[str]:
|
||||||
"""Map a key from the ``validator`` dictionary to the corresponding key(s) in the ``data`` dictionary.
|
"""Map a key from the ``validator`` dictionary to the corresponding key(s) in the ``data`` dictionary.
|
||||||
|
|
||||||
:param key: key from the ``validator`` attribute.
|
:param key: key from the ``validator`` attribute.
|
||||||
@@ -735,15 +769,23 @@ class Schema(object):
|
|||||||
elif isinstance(key, Or):
|
elif isinstance(key, Or):
|
||||||
# At least one of the `Or` entries should be available in the `data` dictionary. If we find at least one of
|
# At least one of the `Or` entries should be available in the `data` dictionary. If we find at least one of
|
||||||
# them in `data`, then we return all found entries so the caller method can validate them all.
|
# them in `data`, then we return all found entries so the caller method can validate them all.
|
||||||
if any([i in self.data for i in key.args]):
|
if any([item in self.data for item in key.args]):
|
||||||
for i in key.args:
|
for item in key.args:
|
||||||
if i in self.data:
|
if item in self.data:
|
||||||
yield i
|
yield item
|
||||||
# If none of the `Or` entries is available in the `data` dictionary, then we return all entries so the
|
# If none of the `Or` entries is available in the `data` dictionary, then we return all entries so the
|
||||||
# caller method will issue errors that they are all absent.
|
# caller method will issue errors that they are all absent.
|
||||||
else:
|
else:
|
||||||
for i in key.args:
|
for item in key.args:
|
||||||
yield i
|
yield item
|
||||||
|
# If the key was defined as a `AtMostOne` object in `validator` attribute, then each of its values
|
||||||
|
# are the keys to access the `data` dictionary.
|
||||||
|
elif isinstance(key, AtMostOne):
|
||||||
|
# Yield back all of the entries from the `data` dictionary, each will be validated and then counted
|
||||||
|
# to inform us if we've provided too many
|
||||||
|
for item in key.args:
|
||||||
|
if item in self.data:
|
||||||
|
yield item
|
||||||
|
|
||||||
|
|
||||||
def _get_type_name(python_type: Any) -> str:
|
def _get_type_name(python_type: Any) -> str:
|
||||||
@@ -1056,7 +1098,10 @@ schema = Schema({
|
|||||||
Optional("safety_margin"): int
|
Optional("safety_margin"): int
|
||||||
},
|
},
|
||||||
Optional("tags"): {
|
Optional("tags"): {
|
||||||
Optional("nofailover"): bool,
|
AtMostOne("nofailover", "failover_priority"): Case({
|
||||||
|
"nofailover": bool,
|
||||||
|
"failover_priority": IntValidator(min=0, raise_assert=True),
|
||||||
|
}),
|
||||||
Optional("clonefrom"): bool,
|
Optional("clonefrom"): bool,
|
||||||
Optional("noloadbalance"): bool,
|
Optional("noloadbalance"): bool,
|
||||||
Optional("replicatefrom"): str,
|
Optional("replicatefrom"): str,
|
||||||
|
|||||||
@@ -151,6 +151,53 @@ class TestConfig(unittest.TestCase):
|
|||||||
def test_invalid_path(self):
|
def test_invalid_path(self):
|
||||||
self.assertRaises(ConfigParseError, Config, 'postgres0')
|
self.assertRaises(ConfigParseError, Config, 'postgres0')
|
||||||
|
|
||||||
|
@patch.object(Config, 'get')
|
||||||
|
@patch('patroni.config.logger')
|
||||||
|
def test__validate_failover_tags(self, mock_logger, mock_get):
|
||||||
|
"""Ensures that only one of `nofailover` or `failover_priority` can be provided"""
|
||||||
|
mock_logger.warning.reset_mock()
|
||||||
|
config = Config("postgres0.yml")
|
||||||
|
# Providing one of `nofailover` or `failover_priority` is fine
|
||||||
|
just_nofailover = {"nofailover": True}
|
||||||
|
mock_get.side_effect = [just_nofailover] * 2
|
||||||
|
self.assertIsNone(config._validate_failover_tags())
|
||||||
|
mock_logger.warning.assert_not_called()
|
||||||
|
just_failover_priority = {"failover_priority": 1}
|
||||||
|
mock_get.side_effect = [just_failover_priority] * 2
|
||||||
|
self.assertIsNone(config._validate_failover_tags())
|
||||||
|
mock_logger.warning.assert_not_called()
|
||||||
|
# Providing both `nofailover` and `failover_priority` is fine if consistent
|
||||||
|
consistent_false = {"nofailover": False, "failover_priority": 1}
|
||||||
|
mock_get.side_effect = [consistent_false] * 2
|
||||||
|
self.assertIsNone(config._validate_failover_tags())
|
||||||
|
mock_logger.warning.assert_not_called()
|
||||||
|
consistent_true = {"nofailover": True, "failover_priority": 0}
|
||||||
|
mock_get.side_effect = [consistent_true] * 2
|
||||||
|
self.assertIsNone(config._validate_failover_tags())
|
||||||
|
mock_logger.warning.assert_not_called()
|
||||||
|
# Providing both inconsistently should log a warning
|
||||||
|
inconsistent_false = {"nofailover": False, "failover_priority": 0}
|
||||||
|
mock_get.side_effect = [inconsistent_false] * 2
|
||||||
|
self.assertIsNone(config._validate_failover_tags())
|
||||||
|
mock_logger.warning.assert_called_once_with(
|
||||||
|
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
|
||||||
|
+ ' Defaulting to nofailover: %s',
|
||||||
|
False,
|
||||||
|
0,
|
||||||
|
False
|
||||||
|
)
|
||||||
|
mock_logger.warning.reset_mock()
|
||||||
|
inconsistent_true = {"nofailover": True, "failover_priority": 1}
|
||||||
|
mock_get.side_effect = [inconsistent_true] * 2
|
||||||
|
self.assertIsNone(config._validate_failover_tags())
|
||||||
|
mock_logger.warning.assert_called_once_with(
|
||||||
|
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
|
||||||
|
+ ' Defaulting to nofailover: %s',
|
||||||
|
True,
|
||||||
|
1,
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
def test__process_postgresql_parameters(self):
|
def test__process_postgresql_parameters(self):
|
||||||
expected_params = {
|
expected_params = {
|
||||||
'f.oo': 'bar', # not in ConfigHandler.CMDLINE_OPTIONS
|
'f.oo': 'bar', # not in ConfigHandler.CMDLINE_OPTIONS
|
||||||
|
|||||||
+11
-2
@@ -94,11 +94,12 @@ def get_cluster_initialized_with_leader_and_failsafe():
|
|||||||
|
|
||||||
def get_node_status(reachable=True, in_recovery=True, dcs_last_seen=0,
|
def get_node_status(reachable=True, in_recovery=True, dcs_last_seen=0,
|
||||||
timeline=2, wal_position=10, nofailover=False,
|
timeline=2, wal_position=10, nofailover=False,
|
||||||
watchdog_failed=False):
|
watchdog_failed=False, failover_priority=1):
|
||||||
def fetch_node_status(e):
|
def fetch_node_status(e):
|
||||||
tags = {}
|
tags = {}
|
||||||
if nofailover:
|
if nofailover:
|
||||||
tags['nofailover'] = True
|
tags['nofailover'] = True
|
||||||
|
tags['failover_priority'] = failover_priority
|
||||||
return _MemberStatus(e, reachable, in_recovery, wal_position,
|
return _MemberStatus(e, reachable, in_recovery, wal_position,
|
||||||
{'tags': tags, 'watchdog_failed': watchdog_failed,
|
{'tags': tags, 'watchdog_failed': watchdog_failed,
|
||||||
'dcs_last_seen': dcs_last_seen, 'timeline': timeline})
|
'dcs_last_seen': dcs_last_seen, 'timeline': timeline})
|
||||||
@@ -153,6 +154,7 @@ zookeeper:
|
|||||||
'postmaster_start_time': str(postmaster_start_time)}
|
'postmaster_start_time': str(postmaster_start_time)}
|
||||||
self.watchdog = Watchdog(self.config)
|
self.watchdog = Watchdog(self.config)
|
||||||
self.request = lambda *args, **kwargs: requests_get(args[0].api_url, *args[1:], **kwargs)
|
self.request = lambda *args, **kwargs: requests_get(args[0].api_url, *args[1:], **kwargs)
|
||||||
|
self.failover_priority = 1
|
||||||
|
|
||||||
|
|
||||||
def run_async(self, func, args=()):
|
def run_async(self, func, args=()):
|
||||||
@@ -1036,6 +1038,11 @@ class TestHa(PostgresInit):
|
|||||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
self.ha.fetch_node_status = get_node_status(in_recovery=False) # accessible, not in_recovery
|
self.ha.fetch_node_status = get_node_status(in_recovery=False) # accessible, not in_recovery
|
||||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
self.ha.fetch_node_status = get_node_status(failover_priority=2) # accessible, in_recovery, higher priority
|
||||||
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
# if there is a higher-priority node but it has a lower WAL position then this node should race
|
||||||
|
self.ha.fetch_node_status = get_node_status(failover_priority=6, wal_position=9)
|
||||||
|
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
self.ha.fetch_node_status = get_node_status(wal_position=11) # accessible, in_recovery, wal position ahead
|
self.ha.fetch_node_status = get_node_status(wal_position=11) # accessible, in_recovery, wal position ahead
|
||||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
# in synchronous_mode consider itself healthy if the former leader is accessible in read-only and ahead of us
|
# in synchronous_mode consider itself healthy if the former leader is accessible in read-only and ahead of us
|
||||||
@@ -1051,7 +1058,9 @@ class TestHa(PostgresInit):
|
|||||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
self.ha.patroni.nofailover = True
|
self.ha.patroni.nofailover = True
|
||||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
self.ha.patroni.nofailover = False
|
self.ha.patroni.nofailover = None
|
||||||
|
self.ha.patroni.failover_priority = 0
|
||||||
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
|
|
||||||
def test_fetch_node_status(self):
|
def test_fetch_node_status(self):
|
||||||
member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})
|
member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})
|
||||||
|
|||||||
+36
-4
@@ -179,10 +179,42 @@ class TestPatroni(unittest.TestCase):
|
|||||||
self.assertTrue(self.p.noloadbalance)
|
self.assertTrue(self.p.noloadbalance)
|
||||||
|
|
||||||
def test_nofailover(self):
|
def test_nofailover(self):
|
||||||
self.p.tags['nofailover'] = True
|
for (nofailover, failover_priority, expected) in [
|
||||||
self.assertTrue(self.p.nofailover)
|
# Without any tags, default is False
|
||||||
self.p.tags['nofailover'] = None
|
(None, None, False),
|
||||||
self.assertFalse(self.p.nofailover)
|
# Setting `nofailover: True` has precedence
|
||||||
|
(True, 0, True),
|
||||||
|
(True, 1, True),
|
||||||
|
# Similarly, setting `nofailover: False` has precedence
|
||||||
|
(False, 0, False),
|
||||||
|
(False, 1, False),
|
||||||
|
# Only when we have `nofailover: None` should we got based on priority
|
||||||
|
(None, 0, True),
|
||||||
|
(None, 1, False),
|
||||||
|
]:
|
||||||
|
with self.subTest(nofailover=nofailover, failover_priority=failover_priority, expected=expected):
|
||||||
|
self.p.tags['nofailover'] = nofailover
|
||||||
|
self.p.tags['failover_priority'] = failover_priority
|
||||||
|
self.assertEqual(self.p.nofailover, expected)
|
||||||
|
|
||||||
|
def test_failover_priority(self):
|
||||||
|
for (nofailover, failover_priority, expected) in [
|
||||||
|
# Without any tags, default is 1
|
||||||
|
(None, None, 1),
|
||||||
|
# Setting `nofailover: True` has precedence (value 0)
|
||||||
|
(True, 0, 0),
|
||||||
|
(True, 1, 0),
|
||||||
|
# Setting `nofailover: False` and `failover_priority: None` gives 1
|
||||||
|
(False, None, 1),
|
||||||
|
# Normal function of failover_priority
|
||||||
|
(None, 0, 0),
|
||||||
|
(None, 1, 1),
|
||||||
|
(None, 2, 2),
|
||||||
|
]:
|
||||||
|
with self.subTest(nofailover=nofailover, failover_priority=failover_priority, expected=expected):
|
||||||
|
self.p.tags['nofailover'] = nofailover
|
||||||
|
self.p.tags['failover_priority'] = failover_priority
|
||||||
|
self.assertEqual(self.p.failover_priority, expected)
|
||||||
|
|
||||||
def test_replicatefrom(self):
|
def test_replicatefrom(self):
|
||||||
self.assertIsNone(self.p.replicatefrom)
|
self.assertIsNone(self.p.replicatefrom)
|
||||||
|
|||||||
@@ -325,3 +325,35 @@ class TestValidator(unittest.TestCase):
|
|||||||
output = "\n".join(errors)
|
output = "\n".join(errors)
|
||||||
self.assertEqual(['postgresql.bin_dir', 'postgresql.bin_name.postgres', 'raft.bind_addr', 'raft.self_addr'],
|
self.assertEqual(['postgresql.bin_dir', 'postgresql.bin_name.postgres', 'raft.bind_addr', 'raft.self_addr'],
|
||||||
parse_output(output))
|
parse_output(output))
|
||||||
|
|
||||||
|
def test_one_of(self, _, __):
|
||||||
|
c = copy.deepcopy(config)
|
||||||
|
# Providing neither is fine
|
||||||
|
del c["tags"]["nofailover"]
|
||||||
|
errors = schema(c)
|
||||||
|
self.assertNotIn("tags Multiple of ('nofailover', 'failover_priority') provided", errors)
|
||||||
|
# Just nofailover is fine
|
||||||
|
c["tags"]["nofailover"] = False
|
||||||
|
errors = schema(c)
|
||||||
|
self.assertNotIn("tags Multiple of ('nofailover', 'failover_priority') provided", errors)
|
||||||
|
# Just failover_priority is fine
|
||||||
|
del c["tags"]["nofailover"]
|
||||||
|
c["tags"]["failover_priority"] = 1
|
||||||
|
errors = schema(c)
|
||||||
|
self.assertNotIn("tags Multiple of ('nofailover', 'failover_priority') provided", errors)
|
||||||
|
# Providing both is not fine
|
||||||
|
c["tags"]["nofailover"] = False
|
||||||
|
errors = schema(c)
|
||||||
|
self.assertIn("tags Multiple of ('nofailover', 'failover_priority') provided", errors)
|
||||||
|
|
||||||
|
def test_failover_priority_int(self, *args):
|
||||||
|
c = copy.deepcopy(config)
|
||||||
|
del c["tags"]["nofailover"]
|
||||||
|
c["tags"]["failover_priority"] = 'a string'
|
||||||
|
errors = schema(c)
|
||||||
|
self.assertIn('tags.failover_priority a string is not an integer', errors)
|
||||||
|
c = copy.deepcopy(config)
|
||||||
|
del c["tags"]["nofailover"]
|
||||||
|
c["tags"]["failover_priority"] = -6
|
||||||
|
errors = schema(c)
|
||||||
|
self.assertIn('tags.failover_priority -6 didn\'t pass validation: Wrong value', errors)
|
||||||
|
|||||||
Reference in New Issue
Block a user