mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-09-02 01:29:36 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0033399a5 | ||
|
|
ce05cb9a10 | ||
|
|
4469c1c390 | ||
|
|
68c7b11970 | ||
|
|
ced75cfe12 | ||
|
|
25ee08f62f | ||
|
|
3e2f553c81 | ||
|
|
ca7188bfdb | ||
|
|
27a1a39f75 | ||
|
|
f99fff6c6a | ||
|
|
63ffb6320f | ||
|
|
d7b4b4e8a9 | ||
|
|
84042f3297 | ||
|
|
a49c534803 | ||
|
|
c4f95200bc | ||
|
|
e96b77c7aa | ||
|
|
b3b3493f3d | ||
|
|
c3bba15ce1 | ||
|
|
d5063bd3d7 | ||
|
|
36bb077964 | ||
|
|
74ed88611f | ||
|
|
e54b88534c | ||
|
|
a6e05b240c | ||
|
|
9ba40f0a25 |
+5
-14
@@ -6,16 +6,6 @@ Release notes
|
|||||||
Version 3.3.0
|
Version 3.3.0
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
.. warning::
|
|
||||||
All older Partoni versions are not compatible with ``ydiff>=1.3``.
|
|
||||||
|
|
||||||
There are the following options available to "fix" the problem:
|
|
||||||
|
|
||||||
1. upgrade Patroni to the latest version
|
|
||||||
2. install ``ydiff<1.3`` after installing Patroni
|
|
||||||
3. install ``cdiff`` module
|
|
||||||
|
|
||||||
|
|
||||||
**New features**
|
**New features**
|
||||||
|
|
||||||
- Add ability to pass ``auth_data`` to Zookeeper client (Aras Mumcuyan)
|
- Add ability to pass ``auth_data`` to Zookeeper client (Aras Mumcuyan)
|
||||||
@@ -26,9 +16,9 @@ Version 3.3.0
|
|||||||
|
|
||||||
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.
|
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)
|
- Support JSON log format (alisalemmi)
|
||||||
|
|
||||||
Apart from ``plain`` (default), Patroni now also supports ``json`` log format. Requires ``python-json-logger>=2.0.2`` library to be installed.
|
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)
|
- Show ``pending_restart_reason`` information (Polina Bungina)
|
||||||
|
|
||||||
@@ -41,7 +31,7 @@ Version 3.3.0
|
|||||||
|
|
||||||
**Improvements**
|
**Improvements**
|
||||||
|
|
||||||
- Implement validation of the ``log`` section (Alexander Kukushkin)
|
- Implement validation of the log section (Alexander Kukushkin)
|
||||||
|
|
||||||
Until now validator was not checking the correctness of the logging configuration provided.
|
Until now validator was not checking the correctness of the logging configuration provided.
|
||||||
|
|
||||||
@@ -70,7 +60,8 @@ Version 3.3.0
|
|||||||
|
|
||||||
- Add compatibility code for ``ydiff`` library version 1.3+ (Alexander Kukushkin)
|
- Add compatibility code for ``ydiff`` library version 1.3+ (Alexander Kukushkin)
|
||||||
|
|
||||||
Patroni is relying on some API from ``ydiff`` that is not public because it is supposed to be just a terminal tool rather than a python module. Unfortunately, the API change in 1.3 broke old Patroni versions.
|
.. 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+4
-8
@@ -13,17 +13,13 @@ def hiddenimports():
|
|||||||
sys.path.pop(0)
|
sys.path.pop(0)
|
||||||
|
|
||||||
|
|
||||||
def resources():
|
|
||||||
import os
|
|
||||||
res_dir = 'patroni/postgresql/available_parameters/'
|
|
||||||
exts = set(f.split('.')[-1] for f in os.listdir(res_dir))
|
|
||||||
return [(res_dir + '*.' + e, res_dir) for e in exts if e.lower() in {'yml', 'yaml'}]
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(['patroni/__main__.py'],
|
a = Analysis(['patroni/__main__.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=None,
|
binaries=None,
|
||||||
datas=resources(),
|
datas=[
|
||||||
|
('patroni/postgresql/available_parameters/*.yml', 'patroni/postgresql/available_parameters'),
|
||||||
|
('patroni/postgresql/available_parameters/*.yaml', 'patroni/postgresql/available_parameters'),
|
||||||
|
],
|
||||||
hiddenimports=hiddenimports(),
|
hiddenimports=hiddenimports(),
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
|
|||||||
+402
-88
@@ -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()
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
:var __version__: the current Patroni version.
|
:var __version__: the current Patroni version.
|
||||||
"""
|
"""
|
||||||
__version__ = '3.3.0'
|
__version__ = '3.2.2'
|
||||||
|
|||||||
+6
-2
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ class TestConsul(unittest.TestCase):
|
|||||||
self.c.set_ttl(20)
|
self.c.set_ttl(20)
|
||||||
self.c._do_refresh_session = Mock()
|
self.c._do_refresh_session = Mock()
|
||||||
self.assertFalse(self.c.take_leader())
|
self.assertFalse(self.c.take_leader())
|
||||||
with patch('time.time', Mock(side_effect=[0, 0, 0, 100, 100, 100])):
|
with patch('time.time', Mock(side_effect=[0, 0, 0, 100, 100])):
|
||||||
self.assertFalse(self.c.take_leader())
|
self.assertFalse(self.c.take_leader())
|
||||||
|
|
||||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||||
|
|||||||
+1
-1
@@ -273,7 +273,7 @@ class TestEtcd3(BaseTestEtcd3):
|
|||||||
|
|
||||||
def test_attempt_to_acquire_leader(self):
|
def test_attempt_to_acquire_leader(self):
|
||||||
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
||||||
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 100, 200, 300])):
|
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 100, 200])):
|
||||||
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
||||||
with patch('time.time', Mock(side_effect=[0, 100, 200, 300, 400])):
|
with patch('time.time', Mock(side_effect=[0, 100, 200, 300, 400])):
|
||||||
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
|
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
|
||||||
|
|||||||
Reference in New Issue
Block a user