mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-25 14:53:37 +00:00
Advance permanent slots for cascading nodes while in failsafe (#3100)
Lets consider a following replication setup:
```
primary->standby1->standby2(replicatefrom: standby1)
```
In this case the `primary` will not create a physical replication slot for standby2, because it is streaming from the `standby1`.
Things will look differently if we have the following dynamic configuration:
```yaml
slots:
primary:
type: physical
standby1:
type: physical
standby2:
type: physical
```
In this case `primary` will also have `standby2` physical replication slot, which periodically must be advanced. So far it was working by taking value of `xlog_location` from the `/members/standby2` key in DCS.
But, when DCS is down and failsafe mode is activate, the `standby2` physical slot on the `primary` will not not be moved, because there was not way to get the latest value of `xlog_location`.
This PR is addressing the problem by making replica nodes to return their `xlog_location` as `lsn` header in the response on `POST /failsafe` REST API request. The current primary will use these values to advance replication slots for nodes with `replicatefrom` tag.
This commit is contained in:
@@ -76,7 +76,7 @@ Feature: dcs failsafe mode
|
||||
@dcs-failsafe
|
||||
Scenario: scale to three-node cluster
|
||||
Given I start postgres0
|
||||
And I start postgres2
|
||||
And I configure and start postgres2 with a tag replicatefrom postgres0
|
||||
Then "members/postgres2" key in DCS has state=running after 10 seconds
|
||||
And "members/postgres0" key in DCS has state=running after 20 seconds
|
||||
And Response on GET http://127.0.0.1:8008/failsafe contains postgres2 after 10 seconds
|
||||
@@ -86,13 +86,14 @@ Feature: dcs failsafe mode
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
Scenario: make sure permanent slots exist on replicas
|
||||
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"slots":{"dcs_slot_0":null,"dcs_slot_2":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
|
||||
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"slots":{"postgres2":0,"dcs_slot_0":null,"dcs_slot_2":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
|
||||
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
|
||||
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
|
||||
When I get all changes from physical slot dcs_slot_1 on postgres1
|
||||
Then physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
And physical slot postgres2 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check three-node cluster is functioning while DCS is down
|
||||
@@ -114,3 +115,4 @@ Feature: dcs failsafe mode
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
And physical slot postgres2 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
|
||||
+5
-3
@@ -752,7 +752,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
"""Handle a ``POST`` request to ``/failsafe`` path.
|
||||
|
||||
Writes a response with HTTP status ``200`` if this node is a Standby, or with HTTP status ``500`` if this is
|
||||
the primary.
|
||||
the primary. In addition to that it returns absolute value of received/replayed LSN in the ``lsn`` header.
|
||||
|
||||
.. note::
|
||||
If ``failsafe_mode`` is not enabled, then write a response with HTTP status ``502``.
|
||||
@@ -760,9 +760,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if self.server.patroni.ha.is_failsafe_mode():
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
message = self.server.patroni.ha.update_failsafe(request) or 'Accepted'
|
||||
ret = self.server.patroni.ha.update_failsafe(request)
|
||||
headers = {'lsn': str(ret)} if isinstance(ret, int) else {}
|
||||
message = ret if isinstance(ret, str) else 'Accepted'
|
||||
code = 200 if message == 'Accepted' else 500
|
||||
self.write_response(code, message)
|
||||
self.write_response(code, message, headers=headers)
|
||||
else:
|
||||
self.send_error(502)
|
||||
|
||||
|
||||
@@ -946,9 +946,9 @@ class Cluster(NamedTuple('Cluster',
|
||||
if not value:
|
||||
value = ret[name] = {}
|
||||
if isinstance(value, dict):
|
||||
# for permanent physical slots we want to get MAX LSN from the `Cluster.slots` and from the
|
||||
# member with the matching name. It is necessary because we may have the replication slot on
|
||||
# the primary that is streaming from the other standby node using the `replicatefrom` tag.
|
||||
# For permanent physical slots we want to get MAX LSN from the `Cluster.slots` and from the
|
||||
# member that does cascading replication with the matching name (see `replicatefrom` tag).
|
||||
# It is necessary because we may have the permanent replication slot on the primary for this node.
|
||||
lsn = max(members.get(name, 0) if self.is_physical_slot(value) else 0, slots.get(name, 0))
|
||||
if lsn:
|
||||
value['lsn'] = lsn
|
||||
|
||||
+135
-28
@@ -21,7 +21,7 @@ from .postgresql.misc import postgres_version_to_int
|
||||
from .postgresql.postmaster import PostmasterProcess
|
||||
from .postgresql.rewind import Rewind
|
||||
from .tags import Tags
|
||||
from .utils import polling_loop, tzutc
|
||||
from .utils import parse_int, polling_loop, tzutc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,14 +88,53 @@ class _MemberStatus(Tags, NamedTuple('_MemberStatus',
|
||||
return None
|
||||
|
||||
|
||||
class _FailsafeResponse(NamedTuple):
|
||||
"""Response on POST ``/failsafe`` API request.
|
||||
|
||||
Consists of the following fields:
|
||||
|
||||
:ivar member_name: member name.
|
||||
:ivar accepted: ``True`` if the member agrees that the current primary will continue running, ``False`` otherwise.
|
||||
:ivar lsn: absolute position of received/replayed location in bytes.
|
||||
"""
|
||||
|
||||
member_name: str
|
||||
accepted: bool
|
||||
lsn: Optional[int]
|
||||
|
||||
|
||||
class Failsafe(object):
|
||||
"""Object that represents failsafe state of the cluster."""
|
||||
|
||||
def __init__(self, dcs: AbstractDCS) -> None:
|
||||
"""Initialize the :class:`Failsafe` object.
|
||||
|
||||
:param dcs: current DCS object, is used only to get current value of ``ttl``.
|
||||
"""
|
||||
self._lock = RLock()
|
||||
self._dcs = dcs
|
||||
self._reset_state()
|
||||
|
||||
def update_slots(self, slots: Dict[str, int]) -> None:
|
||||
"""Assign value to :attr:`_slots`.
|
||||
|
||||
.. note:: This method is only called on the primary node.
|
||||
|
||||
:param slots: a :class:`dict` object with member names as keys and received/replayed LSNs as values.
|
||||
"""
|
||||
with self._lock:
|
||||
self._slots = slots
|
||||
|
||||
def update(self, data: Dict[str, Any]) -> None:
|
||||
"""Update the :class:`Failsafe` object state.
|
||||
|
||||
The last update time is stored and object will be invalidated after ``ttl`` seconds.
|
||||
|
||||
.. note::
|
||||
This method is only called as a result of `POST /failsafe` REST API call.
|
||||
|
||||
:param data: deserialized JSON document from REST API call that contains information about current leader.
|
||||
"""
|
||||
with self._lock:
|
||||
self._last_update = time.time()
|
||||
self._name = data['name']
|
||||
@@ -104,44 +143,75 @@ class Failsafe(object):
|
||||
self._slots = data.get('slots')
|
||||
|
||||
def _reset_state(self) -> None:
|
||||
self._last_update = 0
|
||||
self._name = None
|
||||
self._conn_url = None
|
||||
self._api_url = None
|
||||
self._slots = None
|
||||
"""Reset state of the :class:`Failsafe` object."""
|
||||
self._last_update = 0 # holds information when failsafe was triggered last time.
|
||||
self._name = '' # name of the cluster leader
|
||||
self._conn_url = None # PostgreSQL conn_url of the leader
|
||||
self._api_url = None # Patroni REST api_url of the leader
|
||||
self._slots = None # state of replication slots on the leader
|
||||
|
||||
@property
|
||||
def leader(self) -> Optional[Leader]:
|
||||
"""Return information about current cluster leader if the failsafe mode is active."""
|
||||
with self._lock:
|
||||
if self._last_update + self._dcs.ttl > time.time() and self._name:
|
||||
if self._last_update + self._dcs.ttl > time.time():
|
||||
return Leader('', '', RemoteMember(self._name, {'api_url': self._api_url,
|
||||
'conn_url': self._conn_url,
|
||||
'slots': self._slots}))
|
||||
|
||||
def update_cluster(self, cluster: Cluster) -> Cluster:
|
||||
"""Update and return provided :class:`Cluster` object with fresh values.
|
||||
|
||||
.. note::
|
||||
This method is called when failsafe mode is active and is used to update cluster state
|
||||
with fresh values of replication ``slots`` status and ``xlog_location`` on member nodes.
|
||||
|
||||
:returns: :class:`Cluster` object, either unchanged or updated.
|
||||
"""
|
||||
# Enreach cluster with the real leader if there was a ping from it
|
||||
leader = self.leader
|
||||
if leader:
|
||||
# We rely on the strict order of fields in the namedtuple
|
||||
status = Status(cluster.status.last_lsn, leader.member.data['slots'])
|
||||
cluster = Cluster(*cluster[0:2], leader, status, *cluster[4:])
|
||||
# To advance LSN of replication slots on the primary for nodes that are doing cascading
|
||||
# replication from other nodes we need to update `xlog_location` on respective members.
|
||||
for member in cluster.members:
|
||||
if member.replicatefrom and status.slots and member.name in status.slots:
|
||||
member.data['xlog_location'] = status.slots[member.name]
|
||||
return cluster
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Is used to report in REST API whether the failsafe mode was activated.
|
||||
"""Check whether the failsafe mode is active.
|
||||
|
||||
On primary the self._last_update is set from the
|
||||
set_is_active() method and always returns the correct value.
|
||||
.. note:
|
||||
This method is called from the REST API to report whether the failsafe mode was activated.
|
||||
|
||||
On replicas the self._last_update is set at the moment when
|
||||
the primary performs POST /failsafe REST API calls.
|
||||
The side-effect - it is possible that replicas will show
|
||||
failsafe_is_active values different from the primary."""
|
||||
On primary the :attr:`_last_update` is updated from the :func:`set_is_active` method and always
|
||||
returns the correct value.
|
||||
|
||||
On replicas the :attr:`_last_update` is updated at the moment when the primary performs
|
||||
``POST /failsafe`` REST API calls.
|
||||
|
||||
The side-effect - it is possible that replicas will show ``failsafe_is_active``
|
||||
values different from the primary.
|
||||
|
||||
:returns: ``True`` if failsafe mode is active, ``False`` otherwise.
|
||||
"""
|
||||
|
||||
with self._lock:
|
||||
return self._last_update + self._dcs.ttl > time.time()
|
||||
|
||||
def set_is_active(self, value: float) -> None:
|
||||
"""Update :attr:`_last_update` value.
|
||||
|
||||
.. note::
|
||||
This method is only called on the primary.
|
||||
Effectively it sets expiration time of failsafe mode.
|
||||
If the provided value is ``0``, it disables failsafe mode.
|
||||
|
||||
:param value: time of the last update.
|
||||
"""
|
||||
with self._lock:
|
||||
self._last_update = value
|
||||
if not value:
|
||||
@@ -172,6 +242,12 @@ class Ha(object):
|
||||
# Each member publishes various pieces of information to the DCS using touch_member. This lock protects
|
||||
# the state and publishing procedure to have consistent ordering and avoid publishing stale values.
|
||||
self._member_state_lock = RLock()
|
||||
|
||||
# The last know value of current receive/flush/replay LSN.
|
||||
# We update this value from update_lock() and touch_member() methods, because they fetch it anyway.
|
||||
# This value is used to notify the leader when the failsafe_mode is active without performing any queries.
|
||||
self._last_wal_lsn = None
|
||||
|
||||
# Count of concurrent sync disabling requests. Value above zero means that we don't want to be synchronous
|
||||
# standby. Changes protected by _member_state_lock.
|
||||
self._disable_sync = 0
|
||||
@@ -295,7 +371,7 @@ class Ha(object):
|
||||
last_lsn = slots = None
|
||||
if update_status:
|
||||
try:
|
||||
last_lsn = self.state_handler.last_operation()
|
||||
last_lsn = self._last_wal_lsn = self.state_handler.last_operation()
|
||||
slots = self.cluster.filter_permanent_slots(
|
||||
self.state_handler,
|
||||
{**self.state_handler.slots(), slot_name_from_member_name(self.state_handler.name): last_lsn})
|
||||
@@ -377,7 +453,7 @@ class Ha(object):
|
||||
and data['state'] in ['running', 'restarting', 'starting']:
|
||||
try:
|
||||
timeline, wal_position, pg_control_timeline = self.state_handler.timeline_wal_position()
|
||||
data['xlog_location'] = wal_position
|
||||
data['xlog_location'] = self._last_wal_lsn = wal_position
|
||||
if not timeline: # running as a standby
|
||||
replication_state = self.state_handler.replication_state()
|
||||
if replication_state:
|
||||
@@ -921,23 +997,38 @@ class Ha(object):
|
||||
pool.join()
|
||||
return results
|
||||
|
||||
def update_failsafe(self, data: Dict[str, Any]) -> Optional[str]:
|
||||
def update_failsafe(self, data: Dict[str, Any]) -> Union[int, str, None]:
|
||||
"""Update failsafe state.
|
||||
|
||||
:param data: deserialized JSON document from REST API call that contains information about current leader.
|
||||
|
||||
:returns: the reason why caller shouldn't continue as a primary or the current value of received/replayed LSN.
|
||||
"""
|
||||
if self.state_handler.state == 'running' and self.state_handler.role in ('master', 'primary'):
|
||||
return 'Running as a leader'
|
||||
self._failsafe.update(data)
|
||||
return self._last_wal_lsn
|
||||
|
||||
def failsafe_is_active(self) -> bool:
|
||||
return self._failsafe.is_active()
|
||||
|
||||
def call_failsafe_member(self, data: Dict[str, Any], member: Member) -> bool:
|
||||
def call_failsafe_member(self, data: Dict[str, Any], member: Member) -> _FailsafeResponse:
|
||||
"""Call ``POST /failsafe`` REST API request on provided member.
|
||||
|
||||
:param data: data to be send in the POST request.
|
||||
|
||||
:returns: a :class:`_FailsafeResponse` object.
|
||||
"""
|
||||
try:
|
||||
response = self.patroni.request(member, 'post', 'failsafe', data, timeout=2, retries=1)
|
||||
response_data = response.data.decode('utf-8')
|
||||
logger.info('Got response from %s %s: %s', member.name, member.api_url, response_data)
|
||||
return response.status == 200 and response_data == 'Accepted'
|
||||
accepted = response.status == 200 and response_data == 'Accepted'
|
||||
# member may return its current received/replayed LSN in the "lsn" header.
|
||||
return _FailsafeResponse(member.name, accepted, parse_int(response.headers.get('lsn')))
|
||||
except Exception as e:
|
||||
logger.warning("Request failed to %s: POST %s (%s)", member.name, member.api_url, e)
|
||||
return False
|
||||
return _FailsafeResponse(member.name, False, None)
|
||||
|
||||
def check_failsafe_topology(self) -> bool:
|
||||
"""Check whether we could continue to run as a primary by calling all members from the failsafe topology.
|
||||
@@ -957,6 +1048,10 @@ class Ha(object):
|
||||
Standby nodes are using information from the ``slots`` dict to advance position of permanent
|
||||
replication slots while DCS is not accessible in order to avoid indefinite growth of ``pg_wal``.
|
||||
|
||||
Standby nodes are returning their received/replayed location in the ``lsn`` header, which later are
|
||||
used by the primary to advance position of replication slots that for nodes that are doing cascading
|
||||
replication from other nodes. It is required to avoid indefinite growth of ``pg_wal``.
|
||||
|
||||
:returns: ``True`` if all members from the ``/failsafe`` topology agree that this node could continue to
|
||||
run as a ``primary``, or ``False`` if some of standby nodes are not accessible or don't agree.
|
||||
"""
|
||||
@@ -971,7 +1066,7 @@ class Ha(object):
|
||||
try:
|
||||
data['slots'] = {
|
||||
**self.state_handler.slots(),
|
||||
slot_name_from_member_name(self.state_handler.name): self.state_handler.last_operation()
|
||||
slot_name_from_member_name(self.state_handler.name): self._last_wal_lsn
|
||||
}
|
||||
except Exception:
|
||||
logger.exception('Exception when called state_handler.slots()')
|
||||
@@ -981,10 +1076,15 @@ class Ha(object):
|
||||
return True
|
||||
pool = ThreadPool(len(members))
|
||||
call_failsafe_member = functools.partial(self.call_failsafe_member, data)
|
||||
results = pool.map(call_failsafe_member, members)
|
||||
results: List[_FailsafeResponse] = pool.map(call_failsafe_member, members)
|
||||
pool.close()
|
||||
pool.join()
|
||||
return all(results)
|
||||
ret = all(r.accepted for r in results)
|
||||
if ret:
|
||||
# The LSN feedback will be later used to advance position of replication slots
|
||||
# for nodes that are doing cascading replication from other nodes.
|
||||
self._failsafe.update_slots({r.member_name: r.lsn for r in results if r.lsn})
|
||||
return ret
|
||||
|
||||
def is_lagging(self, wal_position: int) -> bool:
|
||||
"""Returns if instance with an wal should consider itself unhealthy to be promoted due to replication lag.
|
||||
@@ -1768,9 +1868,16 @@ class Ha(object):
|
||||
self.load_cluster_from_dcs()
|
||||
global_config.update(self.cluster)
|
||||
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni)
|
||||
except Exception:
|
||||
except Exception as exc1:
|
||||
self.state_handler.reset_cluster_info_state(None)
|
||||
raise
|
||||
if self.is_failsafe_mode():
|
||||
# If DCS is not accessible we want to get the latest value of received/replayed LSN
|
||||
# in order to have it immediately available if the failsafe mode is enabled.
|
||||
try:
|
||||
self._last_wal_lsn = self.state_handler.last_operation()
|
||||
except Exception as exc2:
|
||||
logger.debug('Failed to fetch current wal lsn: %r', exc2)
|
||||
raise exc1
|
||||
|
||||
if self.is_paused():
|
||||
self.watchdog.disable()
|
||||
@@ -1946,6 +2053,7 @@ class Ha(object):
|
||||
self.set_is_leader(True)
|
||||
self._failsafe.set_is_active(time.time())
|
||||
self.watchdog.keepalive()
|
||||
self._sync_replication_slots(True)
|
||||
return 'continue to run as a leader because failsafe mode is enabled and all members are accessible'
|
||||
self._failsafe.set_is_active(0)
|
||||
msg = 'demoting self because DCS is not accessible and I was a leader'
|
||||
@@ -1969,15 +2077,14 @@ class Ha(object):
|
||||
slots: List[str] = []
|
||||
|
||||
# If dcs_failed we don't want to touch replication slots on a leader or replicas if failsafe_mode isn't enabled.
|
||||
if not self.cluster or dcs_failed and (self.is_leader() or not self.is_failsafe_mode()):
|
||||
if not self.cluster or dcs_failed and not self.is_failsafe_mode():
|
||||
return slots
|
||||
|
||||
# It could be that DCS is read-only, or only the leader can't access it.
|
||||
# Only the second one could be handled by `load_cluster_from_dcs()`.
|
||||
# The first one affects advancing logical replication slots on replicas, therefore we rely on
|
||||
# Failsafe.update_cluster(), that will return "modified" Cluster if failsafe mode is active.
|
||||
cluster = self._failsafe.update_cluster(self.cluster)\
|
||||
if self.is_failsafe_mode() and not self.is_leader() else self.cluster
|
||||
cluster = self._failsafe.update_cluster(self.cluster) if self.is_failsafe_mode() else self.cluster
|
||||
if cluster:
|
||||
slots = self.state_handler.slots_handler.sync_replication_slots(cluster, self.patroni)
|
||||
# Don't copy replication slots if failsafe_mode is active
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class MockResponse(object):
|
||||
|
||||
def __init__(self, status_code=200):
|
||||
self.status_code = status_code
|
||||
self.headers = {'content-type': 'json'}
|
||||
self.headers = {'content-type': 'json', 'lsn': 100}
|
||||
self.content = '{}'
|
||||
self.reason = 'Not Found'
|
||||
|
||||
|
||||
+7
-1
@@ -536,6 +536,9 @@ class TestHa(PostgresInit):
|
||||
def test_no_dcs_connection_primary_failsafe(self):
|
||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
|
||||
for m in self.ha.cluster.members:
|
||||
if m.name != self.ha.cluster.leader.name:
|
||||
m.data['tags']['replicatefrom'] = 'test'
|
||||
global_config.update(self.ha.cluster)
|
||||
self.ha.dcs._last_failsafe = self.ha.cluster.failsafe
|
||||
self.ha.state_handler.name = self.ha.cluster.leader.name
|
||||
@@ -551,13 +554,16 @@ class TestHa(PostgresInit):
|
||||
'continue to run as a leader because failsafe mode is enabled and all members are accessible')
|
||||
|
||||
def test_no_dcs_connection_replica_failsafe(self):
|
||||
self.p.last_operation = Mock(side_effect=PostgresConnectionException(''))
|
||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
|
||||
global_config.update(self.ha.cluster)
|
||||
self.ha.update_failsafe({'name': 'leader', 'api_url': 'http://127.0.0.1:8008/patroni',
|
||||
'conn_url': 'postgres://127.0.0.1:5432/postgres', 'slots': {'foo': 1000}})
|
||||
self.p.is_primary = false
|
||||
self.assertEqual(self.ha.run_cycle(), 'DCS is not accessible')
|
||||
with patch('patroni.ha.logger.debug') as mock_logger:
|
||||
self.assertEqual(self.ha.run_cycle(), 'DCS is not accessible')
|
||||
self.assertEqual(mock_logger.call_args_list[0][0][0], 'Failed to fetch current wal lsn: %r')
|
||||
|
||||
def test_no_dcs_connection_replica_failsafe_not_enabled_but_active(self):
|
||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||
|
||||
Reference in New Issue
Block a user