mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-25 14:53:37 +00:00
Merge branch 'master' of github.com:zalando/patroni into feature/quorum-commit
This commit is contained in:
@@ -110,7 +110,7 @@ def install_etcd():
|
||||
|
||||
|
||||
def install_postgres():
|
||||
version = os.environ.get('PGVERSION', '15.1-1')
|
||||
version = os.environ.get('PGVERSION', '16.1-1')
|
||||
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
|
||||
if platform == 'osx':
|
||||
return subprocess.call(['brew', 'install', 'expect', 'postgresql@{0}'.format(version.split('.')[0])])
|
||||
|
||||
@@ -1 +1 @@
|
||||
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '14', 'kubernetes': '15'}
|
||||
|
||||
@@ -30,7 +30,7 @@ def main():
|
||||
unbuffer = ['timeout', '900', 'unbuffer']
|
||||
else:
|
||||
if sys.platform == 'darwin':
|
||||
version = os.environ.get('PGVERSION', '15.1-1')
|
||||
version = os.environ.get('PGVERSION', '16.1-1')
|
||||
path = '/usr/local/opt/postgresql@{0}/bin:.'.format(version.split('.')[0])
|
||||
unbuffer = ['unbuffer']
|
||||
else:
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
env:
|
||||
DCS: ${{ matrix.dcs }}
|
||||
ETCDVERSION: 3.4.23
|
||||
PGVERSION: 15.1-1 # for windows and macos
|
||||
PGVERSION: 16.1-1 # for windows and macos
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
- uses: jakebailey/pyright-action@v1
|
||||
with:
|
||||
version: 1.1.336
|
||||
version: 1.1.338
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
||||
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||
ARG PG_MAJOR=15
|
||||
ARG PG_MAJOR=16
|
||||
ARG COMPRESS=false
|
||||
ARG PGHOME=/home/postgres
|
||||
ARG PGDATA=$PGHOME/data
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
||||
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||
ARG PG_MAJOR=15
|
||||
ARG PG_MAJOR=16
|
||||
ARG COMPRESS=false
|
||||
ARG PGHOME=/home/postgres
|
||||
ARG PGDATA=$PGHOME/data
|
||||
@@ -40,7 +40,7 @@ RUN set -ex \
|
||||
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
|
||||
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||
&& apt-get update -y \
|
||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
|
||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-12.1; \
|
||||
fi \
|
||||
\
|
||||
# Cleanup all locales but en_US.UTF-8
|
||||
|
||||
@@ -27,19 +27,19 @@ services:
|
||||
ETCD_UNSUPPORTED_ARCH: arm64
|
||||
container_name: demo-etcd1
|
||||
hostname: etcd1
|
||||
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
|
||||
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
|
||||
|
||||
etcd2:
|
||||
<<: *etcd
|
||||
container_name: demo-etcd2
|
||||
hostname: etcd2
|
||||
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
|
||||
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
|
||||
|
||||
etcd3:
|
||||
<<: *etcd
|
||||
container_name: demo-etcd3
|
||||
hostname: etcd3
|
||||
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
|
||||
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
|
||||
|
||||
haproxy:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||
|
||||
+3
-3
@@ -25,19 +25,19 @@ services:
|
||||
ETCD_UNSUPPORTED_ARCH: arm64
|
||||
container_name: demo-etcd1
|
||||
hostname: etcd1
|
||||
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
|
||||
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
|
||||
|
||||
etcd2:
|
||||
<<: *etcd
|
||||
container_name: demo-etcd2
|
||||
hostname: etcd2
|
||||
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
|
||||
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
|
||||
|
||||
etcd3:
|
||||
<<: *etcd
|
||||
container_name: demo-etcd3
|
||||
hostname: etcd3
|
||||
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
|
||||
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
|
||||
|
||||
haproxy:
|
||||
image: ${PATRONI_TEST_IMAGE:-patroni}
|
||||
|
||||
@@ -3,6 +3,44 @@
|
||||
Release notes
|
||||
=============
|
||||
|
||||
Version 3.2.1
|
||||
-------------
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Limit accepted values for ``--format`` argument in ``patronictl`` (Alexander Kukushkin)
|
||||
|
||||
It used to accept any arbitrary string and produce no output if the value wasn't recognized.
|
||||
|
||||
- Verify that replica nodes received checkpoint LSN on shutdown before releasing the leader key (Alexander Kukushkin)
|
||||
|
||||
Previously in some cases, we were using LSN of the SWITCH record that is followed by CHECKPOINT (if archiving mode is enabled). As a result the former primary sometimes had to do ``pg_rewind``, but there would be no data loss involved.
|
||||
|
||||
- Do a real HTTP request when performing node name uniqueness check (Alexander Kukushkin)
|
||||
|
||||
When running Patroni in containers it is possible that the traffic is routed using ``docker-proxy``, which listens on the port and accepts incoming connections. It was causing false positives.
|
||||
|
||||
- Fixed Citus support with Etcd v2 (Alexander Kukushkin)
|
||||
|
||||
Patroni was failing to deploy a new Citus cluster with Etcd v2.
|
||||
|
||||
- Fixed ``pg_rewind`` behavior with Postgres v16+ (Alexander Kukushkin)
|
||||
|
||||
The error message format of ``pg_waldump`` changed in v16 which caused ``pg_rewind`` to be called by Patroni even when it was not necessary.
|
||||
|
||||
- Fixed bug with custom bootstrap (Alexander Kukushkin)
|
||||
|
||||
Patroni was falsely applying ``--command`` argument, which is a bootstrap command itself.
|
||||
|
||||
- Fixed the issue with REST API health check endpoints (Sophia Ruan)
|
||||
|
||||
There were chances that after Postgres restart it could return ``unknown`` state for Postgres because connections were not properly closed.
|
||||
|
||||
- Cache ``postgres --describe-config`` output results (Waynerv)
|
||||
|
||||
They are used to figure out which GUCs are available to validate PostgreSQL configuration and we don't expect this list to change while Patroni is running.
|
||||
|
||||
|
||||
Version 3.2.0
|
||||
-------------
|
||||
|
||||
|
||||
@@ -1073,6 +1073,8 @@ def before_all(context):
|
||||
context.keyfile = os.path.join(context.pctl.output_dir, 'patroni.key')
|
||||
context.certfile = os.path.join(context.pctl.output_dir, 'patroni.crt')
|
||||
try:
|
||||
if sys.platform == 'darwin' and 'GITHUB_ACTIONS' in os.environ:
|
||||
raise Exception
|
||||
with open(os.devnull, 'w') as null:
|
||||
ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni',
|
||||
'-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', context.keyfile,
|
||||
|
||||
@@ -21,3 +21,21 @@ Feature: priority replication
|
||||
And I sleep for 5 seconds
|
||||
Then postgres3 role is the primary after 10 seconds
|
||||
And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds
|
||||
|
||||
Scenario: check conflicting configuration handling
|
||||
When I set nofailover tag in postgres2 config
|
||||
And I issue an empty POST request to http://127.0.0.1:8010/reload
|
||||
Then I receive a response code 202
|
||||
And there is one of ["Conflicting configuration between nofailover: True and failover_priority: 1. Defaulting to nofailover: True"] WARNING in the postgres2 patroni log after 5 seconds
|
||||
And "members/postgres2" key in DCS has tags={'failover_priority': '1', 'nofailover': True} after 10 seconds
|
||||
When I issue a POST request to http://127.0.0.1:8010/failover with {"candidate": "postgres2"}
|
||||
Then I receive a response code 412
|
||||
And I receive a response text "failover is not possible: no good candidates have been found"
|
||||
When I reset nofailover tag in postgres1 config
|
||||
And I issue an empty POST request to http://127.0.0.1:8009/reload
|
||||
Then I receive a response code 202
|
||||
And there is one of ["Conflicting configuration between nofailover: False and failover_priority: 0. Defaulting to nofailover: False"] WARNING in the postgres1 patroni log after 5 seconds
|
||||
And "members/postgres1" key in DCS has tags={'failover_priority': '0', 'nofailover': False} after 10 seconds
|
||||
And I issue a POST request to http://127.0.0.1:8010/failover with {"candidate": "postgres1"}
|
||||
Then I receive a response code 200
|
||||
And postgres1 role is the primary after 10 seconds
|
||||
|
||||
@@ -123,6 +123,6 @@ def check_patroni_log(context, message_list, level, node, timeout):
|
||||
messsages_of_level = context.pctl.read_patroni_log(node, level)
|
||||
if any(any(message in line for line in messsages_of_level) for message in message_list):
|
||||
break
|
||||
time.sleep(1)
|
||||
sleep(1)
|
||||
else:
|
||||
assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds"
|
||||
|
||||
@@ -128,6 +128,12 @@ def scheduled_restart(context, url, in_seconds, data):
|
||||
context.execute_steps(u"""Given I issue a POST request to {0}/restart with {1}""".format(url, json.dumps(data)))
|
||||
|
||||
|
||||
@step('I {action:w} {tag:w} tag in {pg_name:w} config')
|
||||
def add_bool_tag_to_config(context, action, tag, pg_name):
|
||||
value = action == 'set'
|
||||
context.pctl.add_tag_to_config(pg_name, tag, value)
|
||||
|
||||
|
||||
@step('I add tag {tag:w} {value:w} to {pg_name:w} config')
|
||||
def add_tag_to_config(context, tag, value, pg_name):
|
||||
context.pctl.add_tag_to_config(pg_name, tag, value)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM postgres:15
|
||||
FROM postgres:16
|
||||
LABEL maintainer="Alexander Kukushkin <[email protected]>"
|
||||
|
||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM postgres:15
|
||||
FROM postgres:16
|
||||
LABEL maintainer="Alexander Kukushkin <[email protected]>"
|
||||
|
||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -11,7 +11,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
## Make sure we have a en_US.UTF-8 locale available
|
||||
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
||||
apt-get install -y postgresql-server-dev-15 \
|
||||
apt-get install -y postgresql-server-dev-16 \
|
||||
gcc make autoconf \
|
||||
libc6-dev flex libcurl4-gnutls-dev \
|
||||
libicu-dev libkrb5-dev liblz4-dev \
|
||||
@@ -24,7 +24,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
|
||||
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||
&& apt-get update -y \
|
||||
&& apt-get -y install postgresql-15-citus-12.0; \
|
||||
&& apt-get -y install postgresql-16-citus-12.1; \
|
||||
fi \
|
||||
&& pip3 install --break-system-packages setuptools \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
@@ -38,7 +38,7 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
&& chmod 664 /etc/passwd \
|
||||
# Clean up
|
||||
&& apt-get remove -y git python3-pip python3-wheel \
|
||||
postgresql-server-dev-15 gcc make autoconf \
|
||||
postgresql-server-dev-16 gcc make autoconf \
|
||||
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
|
||||
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||
&& apt-get autoremove -y \
|
||||
|
||||
+7
-6
@@ -68,7 +68,7 @@ class Patroni(AbstractPatroniDaemon, Tags):
|
||||
self.watchdog = Watchdog(self.config)
|
||||
self.load_dynamic_configuration()
|
||||
|
||||
self.postgresql = Postgresql(self.config['postgresql'])
|
||||
self.postgresql = Postgresql(self.config['postgresql'], self.dcs.mpp)
|
||||
self.api = RestApiServer(self, self.config['restapi'])
|
||||
self.ha = Ha(self)
|
||||
|
||||
@@ -229,11 +229,6 @@ def patroni_main(configfile: str) -> None:
|
||||
|
||||
:param configfile: path to Patroni configuration file.
|
||||
"""
|
||||
from multiprocessing import freeze_support
|
||||
|
||||
# Windows executables created by PyInstaller are frozen, thus we need to enable frozen support for
|
||||
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
|
||||
freeze_support()
|
||||
abstract_main(Patroni, configfile)
|
||||
|
||||
|
||||
@@ -335,6 +330,12 @@ def main() -> None:
|
||||
``patroni`` daemon as another process. In that case relevant signals received by the main process and forwarded
|
||||
to ``patroni`` daemon process.
|
||||
"""
|
||||
from multiprocessing import freeze_support
|
||||
|
||||
# Executables created by PyInstaller are frozen, thus we need to enable frozen support for
|
||||
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
|
||||
freeze_support()
|
||||
|
||||
check_psycopg()
|
||||
|
||||
args = process_arguments()
|
||||
|
||||
+3
-3
@@ -37,7 +37,7 @@ from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Ret
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_access(func: Callable[['RestApiHandler'], None]) -> Callable[..., None]:
|
||||
def check_access(func: Callable[..., None]) -> Callable[..., None]:
|
||||
"""Check the source ip, authorization header, or client certificates.
|
||||
|
||||
.. note::
|
||||
@@ -1174,8 +1174,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
def do_POST_citus(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/citus`` path.
|
||||
|
||||
Call :func:`~patroni.postgresql.CitusHandler.handle_event` to handle the request, then write a response with
|
||||
HTTP status code ``200``.
|
||||
Call :func:`~patroni.postgresql.mpp.AbstractMPPHandler.handle_event` to handle the request,
|
||||
then write a response with HTTP status code ``200``.
|
||||
|
||||
.. note::
|
||||
If unable to parse the request body, then the request is silently discarded.
|
||||
|
||||
+8
-5
@@ -142,10 +142,10 @@ class Config(object):
|
||||
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
|
||||
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
|
||||
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
|
||||
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
|
||||
self._load_cache()
|
||||
if validator: # patronictl uses validator=None
|
||||
self._load_cache() # we don't want to load anything from local cache for ctl
|
||||
self._validate_failover_tags() # irrelevant for ctl
|
||||
self._cache_needs_saving = False
|
||||
self._validate_failover_tags()
|
||||
|
||||
@property
|
||||
def config_file(self) -> Optional[str]:
|
||||
@@ -356,6 +356,7 @@ class Config(object):
|
||||
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
|
||||
self._local_configuration = configuration
|
||||
self.__effective_configuration = new_configuration
|
||||
self._validate_failover_tags()
|
||||
return True
|
||||
else:
|
||||
logger.info('No local configuration items changed.')
|
||||
@@ -814,10 +815,12 @@ class Config(object):
|
||||
bedrock source of truth)
|
||||
"""
|
||||
tags = self.get('tags', {})
|
||||
if 'nofailover' not in tags:
|
||||
return
|
||||
nofailover_tag = tags.get('nofailover')
|
||||
failover_priority_tag = parse_int(tags.get('failover_priority'))
|
||||
if failover_priority_tag is not None \
|
||||
and (nofailover_tag is True and failover_priority_tag > 0
|
||||
or nofailover_tag is False and failover_priority_tag <= 0):
|
||||
and (bool(nofailover_tag) is True and failover_priority_tag > 0
|
||||
or bool(nofailover_tag) is False and failover_priority_tag <= 0):
|
||||
logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. '
|
||||
'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag)
|
||||
|
||||
@@ -178,7 +178,7 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
|
||||
:yields: formatted lines or blocks that represent a text output of the YAML document.
|
||||
"""
|
||||
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl' 'citus',
|
||||
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl', 'citus',
|
||||
'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'):
|
||||
yield from self._format_config_section(name)
|
||||
|
||||
|
||||
+27
-23
@@ -51,6 +51,7 @@ from .config import Config
|
||||
from .dcs import get_dcs as _get_dcs, AbstractDCS, Cluster, Member
|
||||
from .exceptions import PatroniException
|
||||
from .postgresql.misc import postgres_version_to_int
|
||||
from .postgresql.mpp import get_mpp
|
||||
from .utils import cluster_as_json, patch_config, polling_loop
|
||||
from .request import PatroniRequest
|
||||
from .version import __version__
|
||||
@@ -313,7 +314,7 @@ def ctl(ctx: click.Context, config_file: str, dcs_url: Optional[str], insecure:
|
||||
config = load_config(config_file, dcs_url)
|
||||
# backward compatibility for configuration file where ctl section is not defined
|
||||
config.setdefault('ctl', {})['insecure'] = config.get('ctl', {}).get('insecure') or insecure
|
||||
ctx.obj = {'__config': config}
|
||||
ctx.obj = {'__config': config, '__mpp': get_mpp(config)}
|
||||
|
||||
|
||||
def is_citus_cluster() -> bool:
|
||||
@@ -321,7 +322,7 @@ def is_citus_cluster() -> bool:
|
||||
|
||||
:returns: ``True`` if configuration has ``citus`` section, otherwise ``False``.
|
||||
"""
|
||||
return bool(_get_configuration().get('citus'))
|
||||
return click.get_current_context().obj['__mpp'].is_enabled()
|
||||
|
||||
|
||||
def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
|
||||
@@ -340,12 +341,13 @@ def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
|
||||
config = _get_configuration()
|
||||
config.update({'scope': scope, 'patronictl': True})
|
||||
if group is not None:
|
||||
config['citus'] = {'group': group}
|
||||
config['citus'] = {'group': group, 'database': 'postgres'}
|
||||
config.setdefault('name', scope)
|
||||
try:
|
||||
dcs = _get_dcs(config)
|
||||
if is_citus_cluster() and group is None:
|
||||
dcs.is_citus_coordinator = lambda: True
|
||||
click.get_current_context().obj['__mpp'] = dcs.mpp
|
||||
return dcs
|
||||
except PatroniException as e:
|
||||
raise PatroniCtlException(str(e))
|
||||
@@ -819,7 +821,7 @@ def query(
|
||||
raise PatroniCtlException('You need to specify either --command or --file')
|
||||
sql = command
|
||||
|
||||
connect_parameters = {}
|
||||
connect_parameters: Dict[str, str] = {}
|
||||
if username:
|
||||
connect_parameters['username'] = username
|
||||
if password:
|
||||
@@ -1093,7 +1095,7 @@ def restart(cluster_name: str, group: Optional[int], member_names: List[str],
|
||||
version = click.prompt('Restart if the PostgreSQL version is less than provided (e.g. 9.5.2) ',
|
||||
type=str, default='')
|
||||
|
||||
content = {}
|
||||
content: Dict[str, Any] = {}
|
||||
if pending:
|
||||
content['restart_pending'] = True
|
||||
|
||||
@@ -1185,7 +1187,7 @@ def reinit(cluster_name: str, group: Optional[int], member_names: List[str], for
|
||||
|
||||
|
||||
def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[int],
|
||||
leader: Optional[str], candidate: Optional[str],
|
||||
switchover_leader: Optional[str], candidate: Optional[str],
|
||||
force: bool, scheduled: Optional[str] = None) -> None:
|
||||
"""Perform a failover or a switchover operation in the cluster.
|
||||
|
||||
@@ -1199,7 +1201,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
||||
:param cluster_name: name of the Patroni cluster.
|
||||
:param group: filter Citus group within we should perform a failover or switchover. If ``None``, user will be
|
||||
prompted for filling it -- unless *force* is ``True``, in which case an exception is raised.
|
||||
:param leader: name of the current leader member.
|
||||
:param switchover_leader: name of the leader member passed as switchover option.
|
||||
:param candidate: name of a standby member to be promoted. Nodes that are tagged with ``nofailover`` cannot be used.
|
||||
:param force: perform the failover or switchover without asking for confirmations.
|
||||
:param scheduled: timestamp when the switchover should be scheduled to occur. If ``now`` perform immediately.
|
||||
@@ -1208,10 +1210,11 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
||||
:class:`PatroniCtlException`: if:
|
||||
* Patroni is running on a Citus cluster, but no *group* was specified; or
|
||||
* a switchover was requested by the cluster has no leader; or
|
||||
* *leader* does not match the current leader of the cluster; or
|
||||
* *switchover_leader* does not match the current leader of the cluster; or
|
||||
* cluster has no candidates available for the operation; or
|
||||
* no *candidate* is given for a failover operation; or
|
||||
* *leader* and *candidate* are the same; or
|
||||
* current leader and *candidate* are the same; or
|
||||
* *candidate* is tagged as nofailover; or
|
||||
* *candidate* is not a member of the cluster; or
|
||||
* trying to schedule a switchover in a cluster that is in maintenance mode; or
|
||||
* user aborts the operation.
|
||||
@@ -1231,23 +1234,24 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
||||
|
||||
config = global_config.from_cluster(cluster)
|
||||
|
||||
cluster_leader = cluster.leader and cluster.leader.name
|
||||
# leader has to be be defined for switchover only
|
||||
if action == 'switchover':
|
||||
if cluster.leader is None or not cluster.leader.name:
|
||||
if not cluster_leader:
|
||||
raise PatroniCtlException('This cluster has no leader')
|
||||
|
||||
if leader is None:
|
||||
if switchover_leader is None:
|
||||
if force:
|
||||
leader = cluster.leader.name
|
||||
switchover_leader = cluster_leader
|
||||
else:
|
||||
prompt = 'Standby Leader' if config.is_standby_cluster else 'Primary'
|
||||
leader = click.prompt(prompt, type=str, default=(cluster.leader and cluster.leader.name))
|
||||
switchover_leader = click.prompt(prompt, type=str, default=cluster_leader)
|
||||
|
||||
if cluster.leader.name != leader:
|
||||
raise PatroniCtlException(f'Member {leader} is not the leader of cluster {cluster_name}')
|
||||
if cluster_leader != switchover_leader:
|
||||
raise PatroniCtlException(f'Member {switchover_leader} is not the leader of cluster {cluster_name}')
|
||||
|
||||
# excluding members with nofailover tag
|
||||
candidate_names = [str(m.name) for m in cluster.members if m.name != leader and not m.nofailover]
|
||||
candidate_names = [str(m.name) for m in cluster.members if m.name != cluster_leader and not m.nofailover]
|
||||
# We sort the names for consistent output to the client
|
||||
candidate_names.sort()
|
||||
|
||||
@@ -1260,10 +1264,10 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
||||
if action == 'failover' and not candidate:
|
||||
raise PatroniCtlException('Failover could be performed only to a specific candidate')
|
||||
|
||||
if candidate == leader:
|
||||
raise PatroniCtlException(action.title() + ' target and source are the same.')
|
||||
|
||||
if candidate and candidate not in candidate_names:
|
||||
if candidate == cluster_leader:
|
||||
raise PatroniCtlException(
|
||||
f'Member {candidate} is already the leader of cluster {cluster_name}')
|
||||
raise PatroniCtlException(
|
||||
f'Member {candidate} does not exist in cluster {cluster_name} or is tagged as nofailover')
|
||||
|
||||
@@ -1272,7 +1276,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
||||
config.is_synchronous_mode,
|
||||
not cluster.sync.is_empty,
|
||||
not cluster.sync.matches(candidate, True))):
|
||||
if click.confirm(f'Are you sure you want to failover to the asynchronous node {candidate}'):
|
||||
if not click.confirm(f'Are you sure you want to failover to the asynchronous node {candidate}?'):
|
||||
raise PatroniCtlException('Aborting ' + action)
|
||||
|
||||
scheduled_at_str = None
|
||||
@@ -1292,7 +1296,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
||||
|
||||
failover_value = {'candidate': candidate}
|
||||
if action == 'switchover':
|
||||
failover_value['leader'] = leader
|
||||
failover_value['leader'] = switchover_leader
|
||||
if scheduled_at_str:
|
||||
failover_value['scheduled_at'] = scheduled_at_str
|
||||
|
||||
@@ -1300,7 +1304,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
||||
|
||||
# By now we have established that the leader exists and the candidate exists
|
||||
if not force:
|
||||
demote_msg = f', demoting current leader {cluster.leader.name}' if cluster.leader else ''
|
||||
demote_msg = f', demoting current leader {cluster_leader}' if cluster_leader else ''
|
||||
if scheduled_at_str:
|
||||
# only switchover can be scheduled
|
||||
if not click.confirm(f'Are you sure you want to schedule switchover of cluster '
|
||||
@@ -1334,7 +1338,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
||||
logging.exception(r)
|
||||
logging.warning('Failing over to DCS')
|
||||
click.echo('{0} Could not {1} using Patroni api, falling back to DCS'.format(timestamp(), action))
|
||||
dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at)
|
||||
dcs.manual_failover(switchover_leader, candidate, scheduled_at=scheduled_at)
|
||||
|
||||
output_members(cluster, cluster_name, group=group)
|
||||
|
||||
|
||||
+18
-15
@@ -25,10 +25,9 @@ from ..utils import parse_int
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
from ..postgresql import Postgresql
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
|
||||
SLOT_ADVANCE_AVAILABLE_VERSION = 110000
|
||||
CITUS_COORDINATOR_GROUP_ID = 0
|
||||
citus_group_re = re.compile('^(0|[1-9][0-9]*)$')
|
||||
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -130,10 +129,9 @@ def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
|
||||
p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
|
||||
'patronictl', 'ttl', 'retry_timeout')
|
||||
if p in config})
|
||||
# From citus section we only need "group" parameter, but will propagate everything just in case.
|
||||
if isinstance(config.get('citus'), dict):
|
||||
config[name].update(config['citus'])
|
||||
return dcs_class(config[name])
|
||||
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
return dcs_class(config[name], get_mpp(config))
|
||||
|
||||
available_implementations = ', '.join(sorted([n for n, _ in iter_dcs_classes()]))
|
||||
raise PatroniFatalException("Can not find suitable configuration of distributed configuration store\n"
|
||||
@@ -1351,15 +1349,15 @@ class AbstractDCS(abc.ABC):
|
||||
_SYNC = 'sync'
|
||||
_FAILSAFE = 'failsafe'
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
def __init__(self, config: Dict[str, Any], mpp: 'AbstractMPP') -> None:
|
||||
"""Prepare DCS paths, Citus group ID, initial values for state information and processing dependencies.
|
||||
|
||||
:ivar config: :class:`dict`, reference to config section of selected DCS.
|
||||
i.e.: ``zookeeper`` for zookeeper, ``etcd`` for etcd, etc...
|
||||
"""
|
||||
self._mpp = mpp
|
||||
self._name = config['name']
|
||||
self._base_path = re.sub('/+', '/', '/'.join(['', config.get('namespace', 'service'), config['scope']]))
|
||||
self._citus_group = str(config['group']) if isinstance(config.get('group'), int) else None
|
||||
self._set_loop_wait(config.get('loop_wait', 10))
|
||||
|
||||
self._ctl = bool(config.get('patronictl', False))
|
||||
@@ -1372,6 +1370,11 @@ class AbstractDCS(abc.ABC):
|
||||
self._last_failsafe: Optional[Dict[str, str]] = {}
|
||||
self.event = Event()
|
||||
|
||||
@property
|
||||
def mpp(self) -> 'AbstractMPP':
|
||||
"""Get the effective underlying MPP, if any has been configured."""
|
||||
return self._mpp
|
||||
|
||||
def client_path(self, path: str) -> str:
|
||||
"""Construct the absolute key name from appropriate parts for the DCS type.
|
||||
|
||||
@@ -1380,8 +1383,8 @@ class AbstractDCS(abc.ABC):
|
||||
:returns: absolute key name for the current Patroni cluster.
|
||||
"""
|
||||
components = [self._base_path]
|
||||
if self._citus_group:
|
||||
components.append(self._citus_group)
|
||||
if self._mpp.is_enabled():
|
||||
components.append(str(self._mpp.group))
|
||||
components.append(path.lstrip('/'))
|
||||
return '/'.join(components)
|
||||
|
||||
@@ -1535,9 +1538,9 @@ class AbstractDCS(abc.ABC):
|
||||
def is_citus_coordinator(self) -> bool:
|
||||
""":class:`Cluster` instance has a Citus Coordinator group ID.
|
||||
|
||||
:returns: ``True`` if the given node is running as Citus Coordinator (``group=0``).
|
||||
:returns: ``True`` if the given node is running as the MPP Coordinator.
|
||||
"""
|
||||
return self._citus_group == str(CITUS_COORDINATOR_GROUP_ID)
|
||||
return self._mpp.is_coordinator()
|
||||
|
||||
def get_citus_coordinator(self) -> Optional[Cluster]:
|
||||
"""Load the Patroni cluster for the Citus Coordinator.
|
||||
@@ -1545,10 +1548,10 @@ class AbstractDCS(abc.ABC):
|
||||
.. note::
|
||||
This method is only executed on the worker nodes (``group!=0``) to find the coordinator.
|
||||
|
||||
:returns: Select :class:`Cluster` instance associated with the Citus Coordinator group ID.
|
||||
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
|
||||
"""
|
||||
try:
|
||||
return self.__get_patroni_cluster(f'{self._base_path}/{CITUS_COORDINATOR_GROUP_ID}/')
|
||||
return self.__get_patroni_cluster(f'{self._base_path}/{self._mpp.coordinator_group_id}/')
|
||||
except Exception as e:
|
||||
logger.error('Failed to load Citus coordinator cluster from %s: %r', self.__class__.__name__, e)
|
||||
return None
|
||||
@@ -1562,7 +1565,7 @@ class AbstractDCS(abc.ABC):
|
||||
groups = self._load_cluster(self._base_path + '/', self._citus_cluster_loader)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(groups, dict)
|
||||
cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
|
||||
cluster = groups.pop(self._mpp.coordinator_group_id, Cluster.empty())
|
||||
cluster.workers.update(groups)
|
||||
return cluster
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ from urllib.parse import urlencode, urlparse, quote
|
||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
@@ -232,8 +233,8 @@ def service_name_from_scope_name(scope_name: str) -> str:
|
||||
|
||||
class Consul(AbstractDCS):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Consul, self).__init__(config)
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(Consul, self).__init__(config, mpp)
|
||||
self._base_path = self._base_path[1:]
|
||||
self._scope = config['scope']
|
||||
self._session = None
|
||||
@@ -423,7 +424,7 @@ class Consul(AbstractDCS):
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||
if results is None:
|
||||
return Cluster.empty()
|
||||
nodes = {}
|
||||
nodes: Dict[str, Dict[str, Any]] = {}
|
||||
for node in results:
|
||||
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
||||
nodes[node['Key'][len(path):]] = node
|
||||
@@ -435,7 +436,7 @@ class Consul(AbstractDCS):
|
||||
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
|
||||
for node in results or []:
|
||||
key = node['Key'][len(path):].split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
||||
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
||||
clusters[int(key[0])][key[1]] = node
|
||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||
@@ -666,7 +667,7 @@ class Consul(AbstractDCS):
|
||||
if ret: # We have no other choise, only read after write :(
|
||||
if not retry.ensure_deadline(0.5):
|
||||
return False
|
||||
_, ret = self.retry(self._client.kv.get, self.sync_path)
|
||||
_, ret = self.retry(self._client.kv.get, self.sync_path, consistency='consistent')
|
||||
if ret and (ret.get('Value') or b'').decode('utf-8') == value:
|
||||
return ret['ModifyIndex']
|
||||
return False
|
||||
|
||||
+7
-6
@@ -22,8 +22,9 @@ from urllib3 import Timeout
|
||||
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..request import get as requests_get
|
||||
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -470,9 +471,9 @@ class EtcdClient(AbstractEtcdClientWithFailover):
|
||||
|
||||
class AbstractEtcd(AbstractDCS):
|
||||
|
||||
def __init__(self, config: Dict[str, Any], client_cls: Type[AbstractEtcdClientWithFailover],
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP, client_cls: Type[AbstractEtcdClientWithFailover],
|
||||
retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
|
||||
super(AbstractEtcd, self).__init__(config)
|
||||
super(AbstractEtcd, self).__init__(config, mpp)
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=retry_errors_cls)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
@@ -645,8 +646,8 @@ def catch_etcd_errors(func: Callable[..., Any]) -> Any:
|
||||
|
||||
class Etcd(AbstractEtcd):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(Etcd, self).__init__(config, mpp, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
|
||||
self.__do_not_watch = False
|
||||
|
||||
@property
|
||||
@@ -726,7 +727,7 @@ class Etcd(AbstractEtcd):
|
||||
clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
|
||||
for node in result.leaves:
|
||||
key = node.key[len(result.key):].lstrip('/').split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
||||
clusters[int(key[0])][key[1]] = node
|
||||
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@ from threading import Condition, Lock, Thread
|
||||
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
||||
|
||||
from . import ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, catch_return_false_exception, citus_group_re
|
||||
TimelineHistory, catch_return_false_exception
|
||||
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
|
||||
from ..exceptions import DCSError, PatroniException
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -671,8 +672,9 @@ class PatroniEtcd3Client(Etcd3Client):
|
||||
|
||||
class Etcd3(AbstractEtcd):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition))
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(Etcd3, self).__init__(config, mpp, PatroniEtcd3Client,
|
||||
(DeadlineExceeded, Unavailable, FailedPrecondition))
|
||||
self.__do_not_watch = False
|
||||
self._lease = None
|
||||
self._last_lease_refresh = 0
|
||||
@@ -796,7 +798,7 @@ class Etcd3(AbstractEtcd):
|
||||
path = self._base_path + '/'
|
||||
for node in self._client.get_cluster(path):
|
||||
key = node['key'][len(path):].split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
||||
clusters[int(key[0])][key[1]] = node
|
||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, List, Union
|
||||
|
||||
from . import Cluster
|
||||
from .zookeeper import ZooKeeper
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..request import get as requests_get
|
||||
from ..utils import uri
|
||||
|
||||
@@ -66,10 +67,10 @@ class ExhibitorEnsembleProvider(object):
|
||||
|
||||
class Exhibitor(ZooKeeper):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
interval = config.get('poll_interval', 300)
|
||||
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
|
||||
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts})
|
||||
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts}, mpp)
|
||||
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||
|
||||
+10
-10
@@ -19,9 +19,9 @@ from urllib3.exceptions import HTTPError
|
||||
from threading import Condition, Lock, Thread
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
|
||||
Retry, RetryFailedError, tzutc, uri, USER_AGENT
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -748,7 +748,7 @@ class Kubernetes(AbstractDCS):
|
||||
|
||||
_CITUS_LABEL = 'citus-group'
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
self._labels = deepcopy(config['labels'])
|
||||
self._labels[config.get('scope_label', 'cluster-name')] = config['scope']
|
||||
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
|
||||
@@ -759,9 +759,9 @@ class Kubernetes(AbstractDCS):
|
||||
self._standby_leader_label_value = config.get('standby_leader_label_value', 'master')
|
||||
self._tmp_role_label = config.get('tmp_role_label')
|
||||
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
|
||||
super(Kubernetes, self).__init__({**config, 'namespace': ''})
|
||||
if self._citus_group:
|
||||
self._labels[self._CITUS_LABEL] = self._citus_group
|
||||
super(Kubernetes, self).__init__({**config, 'namespace': ''}, mpp)
|
||||
if self._mpp.is_enabled():
|
||||
self._labels[self._CITUS_LABEL] = str(self._mpp.group)
|
||||
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=KubernetesRetriableException)
|
||||
@@ -944,12 +944,12 @@ class Kubernetes(AbstractDCS):
|
||||
|
||||
for name, pod in path['pods'].items():
|
||||
group = pod.metadata.labels.get(self._CITUS_LABEL)
|
||||
if group and citus_group_re.match(group):
|
||||
if group and self._mpp.group_re.match(group):
|
||||
clusters[group]['pods'][name] = pod
|
||||
|
||||
for name, kind in path['nodes'].items():
|
||||
group = kind.metadata.labels.get(self._CITUS_LABEL)
|
||||
if group and citus_group_re.match(group):
|
||||
if group and self._mpp.group_re.match(group):
|
||||
clusters[group]['nodes'][name] = kind
|
||||
return {int(group): self._cluster_from_nodes(group, value['nodes'], value['pods'].values())
|
||||
for group, value in clusters.items()}
|
||||
@@ -976,12 +976,12 @@ class Kubernetes(AbstractDCS):
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[Any], Union[Cluster, Dict[int, Cluster]]]
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
group = self._citus_group if path == self.client_path('') else None
|
||||
group = str(self._mpp.group) if self._mpp.is_enabled() and path == self.client_path('') else None
|
||||
return self.__load_cluster(group, loader)
|
||||
|
||||
def get_citus_coordinator(self) -> Optional[Cluster]:
|
||||
try:
|
||||
ret = self.__load_cluster(str(CITUS_COORDINATOR_GROUP_ID), self._cluster_loader)
|
||||
ret = self.__load_cluster(str(self._mpp.coordinator_group_id), self._cluster_loader)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(ret, Cluster)
|
||||
return ret
|
||||
|
||||
+5
-5
@@ -12,9 +12,9 @@ from pysyncobj.transport import TCPTransport, CONNECTION_STATE
|
||||
from pysyncobj.utility import TcpUtility
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, citus_group_re
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import validate_directory
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
@@ -285,8 +285,8 @@ class KVStoreTTL(DynMemberSyncObj):
|
||||
|
||||
class Raft(AbstractDCS):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(Raft, self).__init__(config)
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(Raft, self).__init__(config, mpp)
|
||||
self._ttl = int(config.get('ttl') or 30)
|
||||
|
||||
ready_event = threading.Event()
|
||||
@@ -387,7 +387,7 @@ class Raft(AbstractDCS):
|
||||
response = self._sync_obj.get(path, recursive=True)
|
||||
for key, value in (response or {}).items():
|
||||
key = key[len(path):].split('/', 1)
|
||||
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
||||
clusters[int(key[0])][key[1]] = value
|
||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ from kazoo.retry import RetryFailedError
|
||||
from kazoo.security import ACL, make_acl
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, citus_group_re
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
|
||||
from ..exceptions import DCSError
|
||||
from ..postgresql.mpp import AbstractMPP
|
||||
from ..utils import deep_compare
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
@@ -87,8 +87,8 @@ class PatroniKazooClient(KazooClient):
|
||||
|
||||
class ZooKeeper(AbstractDCS):
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(ZooKeeper, self).__init__(config)
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
super(ZooKeeper, self).__init__(config, mpp)
|
||||
|
||||
hosts: Union[str, List[str]] = config.get('hosts', [])
|
||||
if isinstance(hosts, list):
|
||||
@@ -261,7 +261,7 @@ class ZooKeeper(AbstractDCS):
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
ret: Dict[int, Cluster] = {}
|
||||
for node in self.get_children(path):
|
||||
if citus_group_re.match(node):
|
||||
if self._mpp.group_re.match(node):
|
||||
ret[int(node)] = self._cluster_loader(path + node + '/')
|
||||
return ret
|
||||
|
||||
|
||||
+4
-5
@@ -343,7 +343,7 @@ class Ha(object):
|
||||
if coordinator and coordinator.leader and coordinator.leader.conn_url:
|
||||
try:
|
||||
data = {'type': event,
|
||||
'group': self.state_handler.citus_handler.group(),
|
||||
'group': self.state_handler.citus_handler.group,
|
||||
'leader': self.state_handler.name,
|
||||
'timeout': self.dcs.ttl,
|
||||
'cooldown': self.patroni.config['retry_timeout']}
|
||||
@@ -999,7 +999,7 @@ class Ha(object):
|
||||
self.state_handler.set_role('master')
|
||||
self.process_sync_replication()
|
||||
self.update_cluster_history()
|
||||
self.state_handler.citus_handler.sync_pg_dist_node(self.cluster)
|
||||
self.state_handler.citus_handler.sync_meta_data(self.cluster)
|
||||
return message
|
||||
elif self.state_handler.role in ('master', 'promoted', 'primary'):
|
||||
self.process_sync_replication()
|
||||
@@ -2041,10 +2041,9 @@ class Ha(object):
|
||||
logger.fatal('system ID mismatch, node %s belongs to a different cluster: %s != %s',
|
||||
self.state_handler.name, self.cluster.initialize, data_sysid)
|
||||
sys.exit(1)
|
||||
elif self.cluster.is_unlocked() and not self.is_paused():
|
||||
elif self.cluster.is_unlocked() and not self.is_paused() and not self.state_handler.cb_called:
|
||||
# "bootstrap", but data directory is not empty
|
||||
if not self.state_handler.cb_called and self.state_handler.is_running() \
|
||||
and not self.state_handler.is_primary():
|
||||
if self.state_handler.is_running() and not self.state_handler.is_primary():
|
||||
self._join_aborted = True
|
||||
logger.error('No initialize key in DCS and PostgreSQL is running as replica, aborting start')
|
||||
logger.error('Please first start Patroni on the node running as primary')
|
||||
|
||||
@@ -19,8 +19,8 @@ from .callback_executor import CallbackAction, CallbackExecutor
|
||||
from .cancellable import CancellableSubprocess
|
||||
from .config import ConfigHandler, mtime
|
||||
from .connection import ConnectionPool, get_connection_cursor
|
||||
from .citus import CitusHandler
|
||||
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
|
||||
from .mpp import AbstractMPP
|
||||
from .postmaster import PostmasterProcess
|
||||
from .slots import SlotsHandler
|
||||
from .sync import SyncHandler
|
||||
@@ -63,7 +63,7 @@ class Postgresql(object):
|
||||
"pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint, "
|
||||
"pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused()")
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
||||
self.name: str = config['name']
|
||||
self.scope: str = config['scope']
|
||||
self._data_dir: str = config['data_dir']
|
||||
@@ -80,7 +80,7 @@ class Postgresql(object):
|
||||
self._pending_restart = False
|
||||
self.connection_pool = ConnectionPool()
|
||||
self._connection = self.connection_pool.get('heartbeat')
|
||||
self.citus_handler = CitusHandler(self, config.get('citus'))
|
||||
self.citus_handler = mpp.get_handler_impl(self)
|
||||
self.config = ConfigHandler(self, config)
|
||||
self.config.check_directories()
|
||||
|
||||
@@ -118,6 +118,8 @@ class Postgresql(object):
|
||||
# Last known running process
|
||||
self._postmaster_proc = None
|
||||
|
||||
self._available_gucs = None
|
||||
|
||||
if self.is_running():
|
||||
# If we found postmaster process we need to figure out whether postgres is accepting connections
|
||||
self.set_state('starting')
|
||||
@@ -245,7 +247,9 @@ class Postgresql(object):
|
||||
@property
|
||||
def available_gucs(self) -> CaseInsensitiveSet:
|
||||
"""GUCs available in this Postgres server."""
|
||||
return self._get_gucs()
|
||||
if not self._available_gucs:
|
||||
self._available_gucs = self._get_gucs()
|
||||
return self._available_gucs
|
||||
|
||||
def _version_file_exists(self) -> bool:
|
||||
return not self.data_directory_empty() and os.path.isfile(self._version_file)
|
||||
|
||||
@@ -1098,6 +1098,12 @@ class ConfigHandler(object):
|
||||
local_connection_address_changed = True
|
||||
else:
|
||||
logger.info('Changed %s from %s to %s', r[0], r[1], new_value)
|
||||
elif r[0] in self._server_parameters \
|
||||
and not compare_values(r[3], r[2], r[1], self._server_parameters[r[0]]):
|
||||
# Check if any parameter was set back to the current pg_settings value
|
||||
# We can use pg_settings value here, as it is proved to be equal to new_value
|
||||
logger.info('Changed %s from %s to %s', r[0], self._server_parameters[r[0]], r[1])
|
||||
conf_changed = True
|
||||
for param, value in changes.items():
|
||||
if '.' in param:
|
||||
# Check that user-defined-paramters have changed (parameters with period in name)
|
||||
|
||||
@@ -147,7 +147,8 @@ class ConnectionPool:
|
||||
def close(self) -> None:
|
||||
"""Close all named connections from Patroni to PostgreSQL registered in the pool."""
|
||||
with self._lock:
|
||||
if any(conn.close(True) for conn in self._connections.values()):
|
||||
closed_connections = [conn.close(True) for conn in self._connections.values()]
|
||||
if any(closed_connections):
|
||||
logger.info("closed patroni connections to postgres")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Abstract classes for MPP handler.
|
||||
|
||||
MPP stands for Massively Parallel Processing, and Citus belongs to this architecture. Currently, Citus is the only
|
||||
supported MPP cluster. However, we may consider adapting other databases such as TimescaleDB, GPDB, etc. into Patroni.
|
||||
"""
|
||||
import abc
|
||||
|
||||
from typing import Any, Dict, Iterator, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||
|
||||
from ...dcs import Cluster
|
||||
from ...dynamic_loader import iter_classes
|
||||
from ...exceptions import PatroniException
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .. import Postgresql
|
||||
from ...config import Config
|
||||
|
||||
|
||||
class AbstractMPP(abc.ABC):
|
||||
"""An abstract class which should be passed to :class:`AbstractDCS`.
|
||||
|
||||
.. note::
|
||||
We create :class:`AbstractMPP` and :class:`AbstractMPPHandler` to solve the chicken-egg initialization problem.
|
||||
When initializing DCS, we dynamically create an object implementing :class:`AbstractMPP`, later this object is
|
||||
used to instantiate an object implementing :class:`AbstractMPPHandler`.
|
||||
"""
|
||||
|
||||
group_re: Any # re.Pattern[str]
|
||||
|
||||
def __init__(self, config: Dict[str, Union[str, int]]) -> None:
|
||||
"""Init method for :class:`AbstractMPP`.
|
||||
|
||||
:param config: configuration of MPP section.
|
||||
"""
|
||||
self._config = config
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if MPP is enabled for a given MPP.
|
||||
|
||||
.. note::
|
||||
We just check that the :attr:`_config` object isn't empty and expect
|
||||
it to be empty only in case of :class:`Null`.
|
||||
|
||||
:returns: ``True`` if MPP is enabled, otherwise ``False``.
|
||||
"""
|
||||
return bool(self._config)
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def validate_config(config: Any) -> bool:
|
||||
"""Check whether provided config is good for a given MPP.
|
||||
|
||||
:param config: configuration of MPP section.
|
||||
|
||||
:returns: ``True`` is config passes validation, otherwise ``False``.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def group(self) -> Any:
|
||||
"""The group for a given MPP implementation."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def coordinator_group_id(self) -> Any:
|
||||
"""The group id of the coordinator PostgreSQL cluster."""
|
||||
|
||||
def is_coordinator(self) -> bool:
|
||||
"""Check whether this node is running in the coordinator PostgreSQL cluster.
|
||||
|
||||
:returns: ``True`` if MPP is enabled and the group id of this node
|
||||
matches with the :attr:`coordinator_group_id`, otherwise ``False``.
|
||||
"""
|
||||
return self.is_enabled() and self.group == self.coordinator_group_id
|
||||
|
||||
def is_worker(self) -> bool:
|
||||
"""Check whether this node is running as a MPP worker PostgreSQL cluster.
|
||||
|
||||
:returns: ``True`` if MPP is enabled and this node is known to be not running
|
||||
as the coordinator PostgreSQL cluster, otherwise ``False``.
|
||||
"""
|
||||
return self.is_enabled() and not self.is_coordinator()
|
||||
|
||||
def _get_handler_cls(self) -> Iterator[Type['AbstractMPPHandler']]:
|
||||
"""Find Handler classes inherited from a class type of this object.
|
||||
|
||||
:yields: handler classes for this object.
|
||||
"""
|
||||
for cls in self.__class__.__subclasses__():
|
||||
if issubclass(cls, AbstractMPPHandler) and cls.__name__.startswith(self.__class__.__name__):
|
||||
yield cls
|
||||
|
||||
def get_handler_impl(self, postgresql: 'Postgresql') -> 'AbstractMPPHandler':
|
||||
"""Find and instantiate Handler implementation of this object.
|
||||
|
||||
:param postgresql: a reference to :class:`Postgresql` object.
|
||||
|
||||
:raises:
|
||||
:exc:`PatroniException`: if the Handler class haven't been found.
|
||||
|
||||
:returns: an instantiated class that implements Handler for this object.
|
||||
"""
|
||||
for cls in self._get_handler_cls():
|
||||
return cls(postgresql, self._config)
|
||||
raise PatroniException(f'Failed to initialize {self.__class__.__name__}Handler object')
|
||||
|
||||
|
||||
class AbstractMPPHandler(AbstractMPP):
|
||||
"""An abstract class which defines interfaces that should be implemented by real handlers."""
|
||||
|
||||
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
|
||||
"""Init method for :class:`AbstractMPPHandler`.
|
||||
|
||||
:param postgresql: a reference to :class:`Postgresql` object.
|
||||
:param config: configuration of MPP section.
|
||||
"""
|
||||
super().__init__(config)
|
||||
self._postgresql = postgresql
|
||||
|
||||
@abc.abstractmethod
|
||||
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
|
||||
"""Handle an event sent from a worker node.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
:param event: the event to be handled.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def sync_meta_data(self, cluster: Cluster) -> None:
|
||||
"""Sync meta data on the coordinator.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def on_demote(self) -> None:
|
||||
"""On demote handler.
|
||||
|
||||
Is called when the primary was demoted.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def schedule_cache_rebuild(self) -> None:
|
||||
"""Cache rebuild handler.
|
||||
|
||||
Is called to notify handler that it has to refresh its metadata cache from the database.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def bootstrap(self) -> None:
|
||||
"""Bootstrap handler.
|
||||
|
||||
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
|
||||
"""Adjust GUCs in the current PostgreSQL configuration.
|
||||
|
||||
:param parameters: dictionary of GUCs, with key as GUC name and the corresponding value as current GUC value.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
|
||||
"""Check whether provided replication *slot* existing in the database should not be removed.
|
||||
|
||||
.. note::
|
||||
MPP database may create replication slots for its own use, for example to migrate data between workers
|
||||
using logical replication, and we don't want to suddenly drop them.
|
||||
|
||||
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
|
||||
``plugin``.
|
||||
|
||||
:returns: ``True`` if the replication slots should not be removed, otherwise ``False``.
|
||||
"""
|
||||
|
||||
|
||||
class Null(AbstractMPP):
|
||||
"""Dummy implementation of :class:`AbstractMPP`."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init method for :class:`Null`."""
|
||||
super().__init__({})
|
||||
|
||||
@staticmethod
|
||||
def validate_config(config: Any) -> bool:
|
||||
"""Check whether provided config is good for :class:`Null`.
|
||||
|
||||
:returns: always ``True``.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def group(self) -> None:
|
||||
"""The group for :class:`Null`.
|
||||
|
||||
:returns: always ``None``.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def coordinator_group_id(self) -> None:
|
||||
"""The group id of the coordinator PostgreSQL cluster.
|
||||
|
||||
:returns: always ``None``.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class NullHandler(Null, AbstractMPPHandler):
|
||||
"""Dummy implementation of :class:`AbstractMPPHandler`."""
|
||||
|
||||
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
|
||||
"""Init method for :class:`NullHandler`.
|
||||
|
||||
:param postgresql: a reference to :class:`Postgresql` object.
|
||||
:param config: configuration of MPP section.
|
||||
"""
|
||||
AbstractMPPHandler.__init__(self, postgresql, config)
|
||||
|
||||
def handle_event(self, cluster: Cluster, event: Dict[str, Any]) -> None:
|
||||
"""Handle an event sent from a worker node.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
:param event: the event to be handled.
|
||||
"""
|
||||
|
||||
def sync_meta_data(self, cluster: Cluster) -> None:
|
||||
"""Sync meta data on the coordinator.
|
||||
|
||||
:param cluster: the currently known cluster state from DCS.
|
||||
"""
|
||||
|
||||
def on_demote(self) -> None:
|
||||
"""On demote handler.
|
||||
|
||||
Is called when the primary was demoted.
|
||||
"""
|
||||
|
||||
def schedule_cache_rebuild(self) -> None:
|
||||
"""Cache rebuild handler.
|
||||
|
||||
Is called to notify handler that it has to refresh its metadata cache from the database.
|
||||
"""
|
||||
|
||||
def bootstrap(self) -> None:
|
||||
"""Bootstrap handler.
|
||||
|
||||
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
|
||||
"""
|
||||
|
||||
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
|
||||
"""Adjust GUCs in the current PostgreSQL configuration.
|
||||
|
||||
:param parameters: dictionary of GUCs, with key as GUC name and corresponding value as current GUC value.
|
||||
"""
|
||||
|
||||
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
|
||||
"""Check whether provided replication *slot* existing in the database should not be removed.
|
||||
|
||||
.. note::
|
||||
MPP database may create replication slots for its own use, for example to migrate data between workers
|
||||
using logical replication, and we don't want to suddenly drop them.
|
||||
|
||||
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
|
||||
``plugin``.
|
||||
|
||||
:returns: always ``False``.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
def iter_mpp_classes(
|
||||
config: Optional[Union['Config', Dict[str, Any]]] = None
|
||||
) -> Iterator[Tuple[str, Type[AbstractMPP]]]:
|
||||
"""Attempt to import MPP modules that are present in the given configuration.
|
||||
|
||||
:param config: configuration information with possible MPP names as keys. If given, only attempt to import MPP
|
||||
modules defined in the configuration. Else, if ``None``, attempt to import any supported MPP module.
|
||||
|
||||
:yields: tuples, each containing the module ``name`` and the imported MPP class object.
|
||||
"""
|
||||
yield from iter_classes(__package__, AbstractMPP, config)
|
||||
|
||||
|
||||
def get_mpp(config: Union['Config', Dict[str, Any]]) -> AbstractMPP:
|
||||
"""Attempt to load and instantiate a MPP module from known available implementations.
|
||||
|
||||
:param config: object or dictionary with Patroni configuration.
|
||||
|
||||
:returns: The successfully loaded MPP or fallback to :class:`Null`.
|
||||
"""
|
||||
for name, mpp_class in iter_mpp_classes(config):
|
||||
if mpp_class.validate_config(config[name]):
|
||||
return mpp_class(config[name])
|
||||
return Null()
|
||||
@@ -6,12 +6,15 @@ from threading import Condition, Event, Thread
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from ..dcs import CITUS_COORDINATOR_GROUP_ID, Cluster
|
||||
from ..psycopg import connect, quote_ident
|
||||
from . import AbstractMPP, AbstractMPPHandler
|
||||
from ...dcs import Cluster
|
||||
from ...psycopg import connect, quote_ident, DuplicateDatabase
|
||||
from ...utils import parse_int
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from . import Postgresql
|
||||
from .. import Postgresql
|
||||
|
||||
CITUS_COORDINATOR_GROUP_ID = 0
|
||||
CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,13 +66,45 @@ class PgDistNode(object):
|
||||
return str(self)
|
||||
|
||||
|
||||
class CitusHandler(Thread):
|
||||
class Citus(AbstractMPP):
|
||||
|
||||
def __init__(self, postgresql: 'Postgresql', config: Optional[Dict[str, Union[str, int]]]) -> None:
|
||||
super(CitusHandler, self).__init__()
|
||||
group_re = re.compile('^(0|[1-9][0-9]*)$')
|
||||
|
||||
@staticmethod
|
||||
def validate_config(config: Union[Any, Dict[str, Union[str, int]]]) -> bool:
|
||||
"""Check whether provided config is good for a given MPP.
|
||||
|
||||
:param config: configuration of ``citus`` MPP section.
|
||||
|
||||
:returns: ``True`` is config passes validation, otherwise ``False``.
|
||||
"""
|
||||
return isinstance(config, dict) \
|
||||
and isinstance(config.get('database'), str) \
|
||||
and parse_int(config.get('group')) is not None
|
||||
|
||||
@property
|
||||
def group(self) -> int:
|
||||
"""The group of this Citus node."""
|
||||
return int(self._config['group'])
|
||||
|
||||
@property
|
||||
def coordinator_group_id(self) -> int:
|
||||
"""The group id of the Citus coordinator PostgreSQL cluster."""
|
||||
return CITUS_COORDINATOR_GROUP_ID
|
||||
|
||||
|
||||
class CitusHandler(Citus, AbstractMPPHandler, Thread):
|
||||
"""Define the interfaces for handling an underlying Citus cluster."""
|
||||
|
||||
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
|
||||
""""Initialize a new instance of :class:`CitusHandler`.
|
||||
|
||||
:param postgresql: the Postgres node.
|
||||
:param config: the ``citus`` MPP config section.
|
||||
"""
|
||||
Thread.__init__(self)
|
||||
AbstractMPPHandler.__init__(self, postgresql, config)
|
||||
self.daemon = True
|
||||
self._postgresql = postgresql
|
||||
self._config = config
|
||||
if config:
|
||||
self._connection = postgresql.connection_pool.get(
|
||||
'citus', {'dbname': config['database'],
|
||||
@@ -81,26 +116,19 @@ class CitusHandler(Thread):
|
||||
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node
|
||||
self.schedule_cache_rebuild()
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return isinstance(self._config, dict)
|
||||
|
||||
def group(self) -> Optional[int]:
|
||||
return int(self._config['group']) if isinstance(self._config, dict) else None
|
||||
|
||||
def is_coordinator(self) -> bool:
|
||||
return self.is_enabled() and self.group() == CITUS_COORDINATOR_GROUP_ID
|
||||
|
||||
def is_worker(self) -> bool:
|
||||
return self.is_enabled() and not self.is_coordinator()
|
||||
|
||||
def schedule_cache_rebuild(self) -> None:
|
||||
"""Cache rebuild handler.
|
||||
|
||||
Is called to notify handler that it has to refresh its metadata cache from the database.
|
||||
"""
|
||||
with self._condition:
|
||||
self._schedule_load_pg_dist_node = True
|
||||
|
||||
def on_demote(self) -> None:
|
||||
with self._condition:
|
||||
self._pg_dist_node.clear()
|
||||
self._tasks[:] = []
|
||||
empty_tasks: List[PgDistNode] = []
|
||||
self._tasks[:] = empty_tasks
|
||||
self._in_flight = None
|
||||
|
||||
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
|
||||
@@ -133,8 +161,8 @@ class CitusHandler(Thread):
|
||||
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in rows}
|
||||
return True
|
||||
|
||||
def sync_pg_dist_node(self, cluster: Cluster) -> None:
|
||||
"""Maintain the `pg_dist_node` from the coordinator leader every heartbeat loop.
|
||||
def sync_meta_data(self, cluster: Cluster) -> None:
|
||||
"""Maintain the ``pg_dist_node`` from the coordinator leader every heartbeat loop.
|
||||
|
||||
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
|
||||
@@ -295,16 +323,16 @@ class CitusHandler(Thread):
|
||||
with self._condition:
|
||||
i = self.find_task_by_group(task.group)
|
||||
|
||||
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_pg_dist_node().
|
||||
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_meta_data().
|
||||
if task.timeout is None:
|
||||
# We don't want to override the already existing task created from REST API.
|
||||
if i is not None and self._tasks[i].timeout is not None:
|
||||
return False
|
||||
|
||||
# There is a little race condition with tasks created from REST API - the call made "before" the member
|
||||
# key is updated in DCS. Therefore it is possible that :func:`sync_pg_dist_node` will try to create a
|
||||
# task 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_pg_dist_node()
|
||||
# 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.
|
||||
# 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\
|
||||
and self._in_flight.deadline > time.time():
|
||||
return False
|
||||
@@ -352,9 +380,10 @@ class CitusHandler(Thread):
|
||||
task.wait()
|
||||
|
||||
def bootstrap(self) -> None:
|
||||
if not isinstance(self._config, dict): # self.is_enabled()
|
||||
return
|
||||
"""Bootstrap handler.
|
||||
|
||||
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
|
||||
"""
|
||||
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs,
|
||||
'options': '-c synchronous_commit=local -c statement_timeout=0'}
|
||||
if self._config['database'] != self._postgresql.database:
|
||||
@@ -363,6 +392,8 @@ class CitusHandler(Thread):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CREATE DATABASE {0}'.format(
|
||||
quote_ident(self._config['database'], conn)).encode('utf-8'))
|
||||
except DuplicateDatabase as e:
|
||||
logger.debug('Exception when creating database: %r', e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -370,7 +401,7 @@ class CitusHandler(Thread):
|
||||
conn = connect(**conn_kwargs)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CREATE EXTENSION citus')
|
||||
cur.execute('CREATE EXTENSION IF NOT EXISTS citus')
|
||||
|
||||
superuser = self._postgresql.config.superuser
|
||||
params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser}
|
||||
@@ -387,9 +418,10 @@ class CitusHandler(Thread):
|
||||
conn.close()
|
||||
|
||||
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
"""Adjust GUCs in the current PostgreSQL configuration.
|
||||
|
||||
:param parameters: dictionary of GUCs, with key as GUC name and the corresponding value as current GUC value.
|
||||
"""
|
||||
# citus extension must be on the first place in shared_preload_libraries
|
||||
shared_preload_libraries = list(filter(
|
||||
lambda el: el and el != 'citus',
|
||||
@@ -407,8 +439,18 @@ class CitusHandler(Thread):
|
||||
parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
|
||||
|
||||
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
|
||||
if isinstance(self._config, dict) and self._postgresql.is_primary() and\
|
||||
slot['type'] == 'logical' and slot['database'] == self._config['database']:
|
||||
"""Check whether provided replication *slot* existing in the database should not be removed.
|
||||
|
||||
.. note::
|
||||
MPP database may create replication slots for its own use, for example to migrate data between workers
|
||||
using logical replication, and we don't want to suddenly drop them.
|
||||
|
||||
:param slot: dictionary containing the replication slot settings, like ``name``, ``database``, ``type``, and
|
||||
``plugin``.
|
||||
|
||||
:returns: ``True`` if the replication slots should not be removed, otherwise ``False``.
|
||||
"""
|
||||
if self._postgresql.is_primary() and slot['type'] == 'logical' and slot['database'] == self._config['database']:
|
||||
m = CITUS_SLOT_NAME_RE.match(slot['name'])
|
||||
return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
|
||||
return False
|
||||
@@ -176,7 +176,7 @@ class PostmasterProcess(psutil.Process):
|
||||
return not self.is_running()
|
||||
|
||||
def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
|
||||
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15
|
||||
# These regexps are cross checked against versions PostgreSQL 9.1 .. 16
|
||||
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
|
||||
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
|
||||
"walreceiver|wal sender process|walsender|walwriter|background writer|"
|
||||
|
||||
@@ -291,7 +291,7 @@ class SlotsHandler:
|
||||
:param name: name of the slot to ignore
|
||||
|
||||
:returns: ``True`` if slot *name* matches any slot specified in ``ignore_slots`` configuration,
|
||||
otherwise will pass through and return result of :meth:`CitusHandler.ignore_replication_slot`.
|
||||
otherwise will pass through and return result of :meth:`AbstractMPPHandler.ignore_replication_slot`.
|
||||
"""
|
||||
slot = self._replication_slots[name]
|
||||
if cluster.config:
|
||||
|
||||
+4
-1
@@ -9,7 +9,8 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Connection
|
||||
from psycopg2 import connection, cursor
|
||||
|
||||
__all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError']
|
||||
__all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError',
|
||||
'DuplicateDatabase']
|
||||
|
||||
_legacy = False
|
||||
try:
|
||||
@@ -18,6 +19,7 @@ try:
|
||||
if parse_version(__version__) < MIN_PSYCOPG2:
|
||||
raise ImportError
|
||||
from psycopg2 import connect as _connect, Error, DatabaseError, OperationalError, ProgrammingError
|
||||
from psycopg2.errors import DuplicateDatabase
|
||||
from psycopg2.extensions import adapt
|
||||
|
||||
try:
|
||||
@@ -43,6 +45,7 @@ try:
|
||||
return value.getquoted().decode('utf-8')
|
||||
except ImportError:
|
||||
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
|
||||
from psycopg.errors import DuplicateDatabase
|
||||
|
||||
def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]':
|
||||
"""Call :func:`psycopg.connect` with *dsn* and ``**kwargs``.
|
||||
|
||||
+7
-3
@@ -22,14 +22,18 @@ class Tags(abc.ABC):
|
||||
A custom tag is any tag added to the configuration ``tags`` section that is not one of ``clonefrom``,
|
||||
``nofailover``, ``noloadbalance`` or ``nosync``.
|
||||
|
||||
For the Patroni predefined tags, the returning object will only contain them if they are enabled as they
|
||||
all are boolean values that default to disabled.
|
||||
For most of the Patroni predefined tags, the returning object will only contain them if they are enabled as
|
||||
they all are boolean values that default to disabled.
|
||||
However ``nofailover`` tag is always returned if ``failover_priority`` tag is defined. In this case, we need
|
||||
both values to see if they are contradictory and the ``nofailover`` value should be used.
|
||||
|
||||
:returns: a dictionary of tags set for this node. The key is the tag name, and the value is the corresponding
|
||||
tag value.
|
||||
"""
|
||||
return {tag: value for tag, value in tags.items()
|
||||
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
|
||||
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync'),
|
||||
value,
|
||||
tag == 'nofailover' and 'failover_priority' in tags))}
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
|
||||
+11
-10
@@ -400,22 +400,23 @@ def parse_real(value: Any, base_unit: Optional[str] = None) -> Optional[float]:
|
||||
return convert_to_base_unit(val, unit, base_unit)
|
||||
|
||||
|
||||
def compare_values(vartype: str, unit: Optional[str], old_value: Any, new_value: Any) -> bool:
|
||||
"""Check if *old_value* and *new_value* are equivalent after parsing them as *vartype*.
|
||||
def compare_values(vartype: str, unit: Optional[str], settings_value: Any, config_value: Any) -> bool:
|
||||
"""Check if the value from ``pg_settings`` and from Patroni config are equivalent after parsing them as *vartype*.
|
||||
|
||||
:param vartpe: the target type to parse *old_value* and *new_value* before comparing them. Accepts any among of the
|
||||
following (case sensitive):
|
||||
:param vartype: the target type to parse *settings_value* and *config_value* before comparing them.
|
||||
Accepts any among of the following (case sensitive):
|
||||
|
||||
* ``bool``: parse values using :func:`parse_bool`; or
|
||||
* ``integer``: parse values using :func:`parse_int`; or
|
||||
* ``real``: parse values using :func:`parse_real`; or
|
||||
* ``enum``: parse values as lowercase strings; or
|
||||
* ``string``: parse values as strings. This one is used by default if no valid value is passed as *vartype*.
|
||||
:param unit: base unit to be used as argument when calling :func:`parse_int` or :func:`parse_real` for *new_value*.
|
||||
:param old_value: value to be compared with *new_value*.
|
||||
:param new_value: value to be compared with *old_value*.
|
||||
:param unit: base unit to be used as argument when calling :func:`parse_int` or :func:`parse_real`
|
||||
for *config_value*.
|
||||
:param settings_value: value to be compared with *config_value*.
|
||||
:param config_value: value to be compared with *settings_value*.
|
||||
|
||||
:returns: ``True`` if *old_value* is equivalent to *new_value* when both are parsed as *vartype*.
|
||||
:returns: ``True`` if *settings_value* is equivalent to *config_value* when both are parsed as *vartype*.
|
||||
|
||||
:Example:
|
||||
|
||||
@@ -455,8 +456,8 @@ def compare_values(vartype: str, unit: Optional[str], old_value: Any, new_value:
|
||||
}
|
||||
|
||||
converter = converters.get(vartype) or converters['string']
|
||||
old_converted = converter(old_value, None)
|
||||
new_converted = converter(new_value, unit)
|
||||
old_converted = converter(settings_value, None)
|
||||
new_converted = converter(config_value, unit)
|
||||
|
||||
return old_converted is not None and new_converted is not None and old_converted == new_converted
|
||||
|
||||
|
||||
@@ -937,6 +937,18 @@ validate_etcd = {
|
||||
schema = Schema({
|
||||
"name": str,
|
||||
"scope": str,
|
||||
Optional("log"): {
|
||||
Optional("level"): EnumValidator(('DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR', 'FATAL', 'CRITICAL'),
|
||||
case_sensitive=True, raise_assert=True),
|
||||
Optional("traceback_level"): EnumValidator(('DEBUG', 'ERROR'), raise_assert=True),
|
||||
Optional("format"): str,
|
||||
Optional("dateformat"): str,
|
||||
Optional("max_queue_size"): int,
|
||||
Optional("dir"): str,
|
||||
Optional("file_num"): int,
|
||||
Optional("file_size"): int,
|
||||
Optional("loggers"): dict
|
||||
},
|
||||
Optional("ctl"): {
|
||||
Optional("insecure"): bool,
|
||||
Optional("cacert"): str,
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
:var __version__: the current Patroni version.
|
||||
"""
|
||||
__version__ = '3.2.0'
|
||||
__version__ = '3.2.1'
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ postgresql:
|
||||
# safety_margin: 5
|
||||
|
||||
tags:
|
||||
nofailover: false
|
||||
# failover_priority: 1
|
||||
noloadbalance: false
|
||||
clonefrom: false
|
||||
nosync: false
|
||||
|
||||
+1
-1
@@ -124,6 +124,6 @@ postgresql:
|
||||
#pre_promote: /path/to/pre_promote.sh
|
||||
|
||||
tags:
|
||||
nofailover: false
|
||||
# failover_priority: 1
|
||||
noloadbalance: false
|
||||
clonefrom: false
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ postgresql:
|
||||
# krb_server_keyfile: /var/spool/keytabs/postgres
|
||||
unix_socket_directories: '..' # parent directory of data_dir
|
||||
tags:
|
||||
nofailover: false
|
||||
# failover_priority: 1
|
||||
noloadbalance: false
|
||||
clonefrom: false
|
||||
# replicatefrom: postgresql1
|
||||
|
||||
+59
-35
@@ -12,6 +12,7 @@ import patroni.psycopg as psycopg
|
||||
from patroni.dcs import Leader, Member
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.config import ConfigHandler
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
from patroni.utils import RetryFailedError, tzutc
|
||||
|
||||
|
||||
@@ -25,8 +26,41 @@ mock_available_gucs = PropertyMock(return_value={
|
||||
'max_wal_senders', 'max_worker_processes', 'port', 'search_path', 'shared_preload_libraries',
|
||||
'stats_temp_directory', 'synchronous_standby_names', 'track_commit_timestamp', 'unix_socket_directories',
|
||||
'vacuum_cost_delay', 'vacuum_cost_limit', 'wal_keep_size', 'wal_level', 'wal_log_hints', 'zero_damaged_pages',
|
||||
'autovacuum', 'wal_segment_size', 'wal_block_size', 'shared_buffers', 'wal_buffers',
|
||||
})
|
||||
|
||||
GET_PG_SETTINGS_RESULT = [
|
||||
('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
|
||||
('wal_block_size', '8192', None, 'integer', 'internal'),
|
||||
('shared_buffers', '16384', '8kB', 'integer', 'postmaster'),
|
||||
('wal_buffers', '-1', '8kB', 'integer', 'postmaster'),
|
||||
('max_connections', '100', None, 'integer', 'postmaster'),
|
||||
('max_prepared_transactions', '200', None, 'integer', 'postmaster'),
|
||||
('max_worker_processes', '8', None, 'integer', 'postmaster'),
|
||||
('max_locks_per_transaction', '64', None, 'integer', 'postmaster'),
|
||||
('max_wal_senders', '5', None, 'integer', 'postmaster'),
|
||||
('search_path', 'public', None, 'string', 'user'),
|
||||
('port', '5432', None, 'integer', 'postmaster'),
|
||||
('listen_addresses', '127.0.0.2, 127.0.0.3', None, 'string', 'postmaster'),
|
||||
('autovacuum', 'on', None, 'bool', 'sighup'),
|
||||
('unix_socket_directories', '/tmp', None, 'string', 'postmaster'),
|
||||
('shared_preload_libraries', 'citus', None, 'string', 'postmaster'),
|
||||
('wal_keep_size', '128', 'MB', 'integer', 'sighup'),
|
||||
('cluster_name', 'batman', None, 'string', 'postmaster'),
|
||||
('vacuum_cost_delay', '200', 'ms', 'real', 'user'),
|
||||
('vacuum_cost_limit', '-1', None, 'integer', 'user'),
|
||||
('max_stack_depth', '2048', 'kB', 'integer', 'superuser'),
|
||||
('constraint_exclusion', '', None, 'enum', 'user'),
|
||||
('force_parallel_mode', '1', None, 'enum', 'user'),
|
||||
('zero_damaged_pages', 'off', None, 'bool', 'superuser'),
|
||||
('stats_temp_directory', '/tmp', None, 'string', 'sighup'),
|
||||
('track_commit_timestamp', 'off', None, 'bool', 'postmaster'),
|
||||
('wal_log_hints', 'on', None, 'bool', 'superuser'),
|
||||
('hot_standby', 'on', None, 'bool', 'superuser'),
|
||||
('max_replication_slots', '5', None, 'integer', 'superuser'),
|
||||
('wal_level', 'logical', None, 'enum', 'superuser'),
|
||||
]
|
||||
|
||||
|
||||
class MockResponse(object):
|
||||
|
||||
@@ -95,6 +129,8 @@ class MockCursor(object):
|
||||
sql = sql.decode('utf-8')
|
||||
if sql.startswith('blabla'):
|
||||
raise psycopg.ProgrammingError()
|
||||
if sql.startswith('CREATE DATABASE'):
|
||||
raise psycopg.DuplicateDatabase()
|
||||
elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'):
|
||||
raise psycopg.OperationalError()
|
||||
elif sql.startswith('RetryFailedError'):
|
||||
@@ -133,22 +169,9 @@ class MockCursor(object):
|
||||
('archive_command', 'my archive command'),
|
||||
('cluster_name', 'my_cluster')]
|
||||
elif sql.startswith('SELECT name, setting'):
|
||||
self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
|
||||
('wal_block_size', '8192', None, 'integer', 'internal'),
|
||||
('shared_buffers', '16384', '8kB', 'integer', 'postmaster'),
|
||||
('wal_buffers', '-1', '8kB', 'integer', 'postmaster'),
|
||||
('max_connections', '100', None, 'integer', 'postmaster'),
|
||||
('max_prepared_transactions', '0', None, 'integer', 'postmaster'),
|
||||
('max_worker_processes', '8', None, 'integer', 'postmaster'),
|
||||
('max_locks_per_transaction', '64', None, 'integer', 'postmaster'),
|
||||
('max_wal_senders', '5', None, 'integer', 'postmaster'),
|
||||
('search_path', 'public', None, 'string', 'user'),
|
||||
('port', '5433', None, 'integer', 'postmaster'),
|
||||
('listen_addresses', '*', None, 'string', 'postmaster'),
|
||||
('autovacuum', 'on', None, 'bool', 'sighup'),
|
||||
('unix_socket_directories', '/tmp', None, 'string', 'postmaster')]
|
||||
self.results = GET_PG_SETTINGS_RESULT
|
||||
elif sql.startswith('SELECT COUNT(*) FROM pg_catalog.pg_settings'):
|
||||
self.results = [(1,)]
|
||||
self.results = [(0,)]
|
||||
elif sql.startswith('IDENTIFY_SYSTEM'):
|
||||
self.results = [('1', 3, '0/402EEC0', '')]
|
||||
elif sql.startswith('TIMELINE_HISTORY '):
|
||||
@@ -218,11 +241,11 @@ class PostgresInit(unittest.TestCase):
|
||||
_PARAMETERS = {'wal_level': 'hot_standby', 'max_replication_slots': 5, 'f.oo': 'bar',
|
||||
'search_path': 'public', 'hot_standby': 'on', 'max_wal_senders': 5,
|
||||
'wal_keep_segments': 8, 'wal_log_hints': 'on', 'max_locks_per_transaction': 64,
|
||||
'max_worker_processes': 8, 'max_connections': 100, 'max_prepared_transactions': 0,
|
||||
'max_worker_processes': 8, 'max_connections': 100, 'max_prepared_transactions': 200,
|
||||
'track_commit_timestamp': 'off', 'unix_socket_directories': '/tmp',
|
||||
'trigger_file': 'bla', 'stats_temp_directory': '/tmp', 'zero_damaged_pages': '',
|
||||
'trigger_file': 'bla', 'stats_temp_directory': '/tmp', 'zero_damaged_pages': 'off',
|
||||
'force_parallel_mode': '1', 'constraint_exclusion': '',
|
||||
'max_stack_depth': 'Z', 'vacuum_cost_limit': -1, 'vacuum_cost_delay': 200}
|
||||
'max_stack_depth': 2048, 'vacuum_cost_limit': -1, 'vacuum_cost_delay': 200}
|
||||
|
||||
@patch('patroni.psycopg._connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.CallbackExecutor', Mock())
|
||||
@@ -232,23 +255,24 @@ class PostgresInit(unittest.TestCase):
|
||||
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='primary'))
|
||||
def setUp(self):
|
||||
data_dir = os.path.join('data', 'test0')
|
||||
self.p = Postgresql({'name': 'postgresql0', 'scope': 'batman', 'data_dir': data_dir,
|
||||
'config_dir': data_dir, 'retry_timeout': 10,
|
||||
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
|
||||
'listen': '127.0.0.2, 127.0.0.3:5432',
|
||||
'connect_address': '127.0.0.2:5432', 'proxy_address': '127.0.0.2:5433',
|
||||
'authentication': {'superuser': {'username': 'foo', 'password': 'test'},
|
||||
'replication': {'username': '', 'password': 'rep-pass'},
|
||||
'rewind': {'username': 'rewind', 'password': 'test'}},
|
||||
'remove_data_directory_on_rewind_failure': True,
|
||||
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla', 'use_unix_socket': True,
|
||||
'parameters': self._PARAMETERS,
|
||||
'recovery_conf': {'foo': 'bar'},
|
||||
'pg_hba': ['host all all 0.0.0.0/0 md5'],
|
||||
'pg_ident': ['krb realm postgres'],
|
||||
'callbacks': {'on_start': 'true', 'on_stop': 'true', 'on_reload': 'true',
|
||||
'on_restart': 'true', 'on_role_change': 'true'},
|
||||
'citus': {'group': 0, 'database': 'citus'}})
|
||||
config = {'name': 'postgresql0', 'scope': 'batman', 'data_dir': data_dir,
|
||||
'config_dir': data_dir, 'retry_timeout': 10,
|
||||
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
|
||||
'listen': '127.0.0.2, 127.0.0.3:5432',
|
||||
'connect_address': '127.0.0.2:5432', 'proxy_address': '127.0.0.2:5433',
|
||||
'authentication': {'superuser': {'username': 'foo', 'password': 'test'},
|
||||
'replication': {'username': '', 'password': 'rep-pass'},
|
||||
'rewind': {'username': 'rewind', 'password': 'test'}},
|
||||
'remove_data_directory_on_rewind_failure': True,
|
||||
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla', 'use_unix_socket': True,
|
||||
'parameters': self._PARAMETERS,
|
||||
'recovery_conf': {'foo': 'bar'},
|
||||
'pg_hba': ['host all all 0.0.0.0/0 md5'],
|
||||
'pg_ident': ['krb realm postgres'],
|
||||
'callbacks': {'on_start': 'true', 'on_stop': 'true', 'on_reload': 'true',
|
||||
'on_restart': 'true', 'on_role_change': 'true'},
|
||||
'citus': {'group': 0, 'database': 'citus'}}
|
||||
self.p = Postgresql(config, get_mpp(config))
|
||||
|
||||
|
||||
class BaseTestPostgresql(PostgresInit):
|
||||
|
||||
+23
-19
@@ -1,12 +1,12 @@
|
||||
import time
|
||||
from mock import Mock, patch
|
||||
from patroni.postgresql.citus import CitusHandler
|
||||
from patroni.postgresql.mpp.citus import CitusHandler
|
||||
|
||||
from . import BaseTestPostgresql, MockCursor, psycopg_connect, SleepException
|
||||
from .test_ha import get_cluster_initialized_with_leader
|
||||
|
||||
|
||||
@patch('patroni.postgresql.citus.Thread', Mock())
|
||||
@patch('patroni.postgresql.mpp.citus.Thread', Mock())
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
class TestCitus(BaseTestPostgresql):
|
||||
|
||||
@@ -17,9 +17,9 @@ class TestCitus(BaseTestPostgresql):
|
||||
self.cluster.workers[1] = self.cluster
|
||||
|
||||
@patch('time.time', Mock(side_effect=[100, 130, 160, 190, 220, 250, 280, 310, 340, 370]))
|
||||
@patch('patroni.postgresql.citus.logger.exception', Mock(side_effect=SleepException))
|
||||
@patch('patroni.postgresql.citus.logger.warning')
|
||||
@patch('patroni.postgresql.citus.PgDistNode.wait', Mock())
|
||||
@patch('patroni.postgresql.mpp.citus.logger.exception', Mock(side_effect=SleepException))
|
||||
@patch('patroni.postgresql.mpp.citus.logger.warning')
|
||||
@patch('patroni.postgresql.mpp.citus.PgDistNode.wait', Mock())
|
||||
@patch.object(CitusHandler, 'is_alive', Mock(return_value=True))
|
||||
def test_run(self, mock_logger_warning):
|
||||
# `before_demote` or `before_promote` REST API calls starting a
|
||||
@@ -39,10 +39,10 @@ class TestCitus(BaseTestPostgresql):
|
||||
|
||||
@patch.object(CitusHandler, 'is_alive', Mock(return_value=False))
|
||||
@patch.object(CitusHandler, 'start', Mock())
|
||||
def test_sync_pg_dist_node(self):
|
||||
def test_sync_meta_data(self):
|
||||
with patch.object(CitusHandler, 'is_enabled', Mock(return_value=False)):
|
||||
self.c.sync_pg_dist_node(self.cluster)
|
||||
self.c.sync_pg_dist_node(self.cluster)
|
||||
self.c.sync_meta_data(self.cluster)
|
||||
self.c.sync_meta_data(self.cluster)
|
||||
|
||||
def test_handle_event(self):
|
||||
self.c.handle_event(self.cluster, {})
|
||||
@@ -51,22 +51,22 @@ class TestCitus(BaseTestPostgresql):
|
||||
'leader': 'leader', 'timeout': 30, 'cooldown': 10})
|
||||
|
||||
def test_add_task(self):
|
||||
with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
|
||||
patch('patroni.postgresql.citus.urlparse', Mock(side_effect=Exception)):
|
||||
with patch('patroni.postgresql.mpp.citus.logger.error') as mock_logger, \
|
||||
patch('patroni.postgresql.mpp.citus.urlparse', Mock(side_effect=Exception)):
|
||||
self.c.add_task('', 1, None)
|
||||
mock_logger.assert_called_once()
|
||||
|
||||
with patch('patroni.postgresql.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)
|
||||
mock_logger.assert_called_once()
|
||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Adding the new task:'))
|
||||
|
||||
with patch('patroni.postgresql.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)
|
||||
mock_logger.assert_called_once()
|
||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Overriding existing task:'))
|
||||
|
||||
# add_task called from sync_pg_dist_node should not override already scheduled or in flight task until deadline
|
||||
# add_task called from sync_meta_data 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.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres'))
|
||||
self.c._in_flight = self.c._tasks.pop()
|
||||
@@ -106,7 +106,7 @@ class TestCitus(BaseTestPostgresql):
|
||||
self.c.process_tasks()
|
||||
|
||||
self.c.add_task('after_promote', 0, 'postgres://host3:5432/postgres')
|
||||
with patch('patroni.postgresql.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)):
|
||||
self.c.process_tasks()
|
||||
mock_logger.assert_called_once()
|
||||
@@ -115,7 +115,7 @@ class TestCitus(BaseTestPostgresql):
|
||||
def test_on_demote(self):
|
||||
self.c.on_demote()
|
||||
|
||||
@patch('patroni.postgresql.citus.logger.error')
|
||||
@patch('patroni.postgresql.mpp.citus.logger.error')
|
||||
@patch.object(MockCursor, 'execute', Mock(side_effect=Exception))
|
||||
def test_load_pg_dist_node(self, mock_logger):
|
||||
# load_pg_dist_node() triggers, query fails and exception is property handled
|
||||
@@ -140,10 +140,6 @@ class TestCitus(BaseTestPostgresql):
|
||||
self.assertEqual(parameters['wal_level'], 'logical')
|
||||
self.assertEqual(parameters['citus.local_hostname'], '/tmp')
|
||||
|
||||
def test_bootstrap(self):
|
||||
self.c._config = None
|
||||
self.c.bootstrap()
|
||||
|
||||
def test_ignore_replication_slot(self):
|
||||
self.assertFalse(self.c.ignore_replication_slot({'name': 'foo', 'type': 'physical',
|
||||
'database': 'bar', 'plugin': 'wal2json'}))
|
||||
@@ -161,3 +157,11 @@ class TestCitus(BaseTestPostgresql):
|
||||
'type': 'logical', 'database': 'citus', 'plugin': 'pgoutput'}))
|
||||
self.assertTrue(self.c.ignore_replication_slot({'name': 'citus_shard_split_slot_1_2_3',
|
||||
'type': 'logical', 'database': 'citus', 'plugin': 'citus'}))
|
||||
|
||||
@patch('patroni.postgresql.mpp.citus.logger.debug')
|
||||
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
|
||||
def test_bootstrap_duplicate_database(self, mock_logger):
|
||||
self.c.bootstrap()
|
||||
mock_logger.assert_called_once()
|
||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Exception when creating database'))
|
||||
|
||||
+30
-38
@@ -159,48 +159,40 @@ class TestConfig(unittest.TestCase):
|
||||
@patch('patroni.config.logger')
|
||||
def test__validate_failover_tags(self, mock_logger, mock_get):
|
||||
"""Ensures that only one of `nofailover` or `failover_priority` can be provided"""
|
||||
mock_logger.warning.reset_mock()
|
||||
config = Config("postgres0.yml")
|
||||
|
||||
# Providing one of `nofailover` or `failover_priority` is fine
|
||||
just_nofailover = {"nofailover": True}
|
||||
mock_get.side_effect = [just_nofailover] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
just_failover_priority = {"failover_priority": 1}
|
||||
mock_get.side_effect = [just_failover_priority] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
for single_param in ({"nofailover": True}, {"failover_priority": 1}, {"failover_priority": 0}):
|
||||
mock_get.side_effect = [single_param] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
# Providing both `nofailover` and `failover_priority` is fine if consistent
|
||||
consistent_false = {"nofailover": False, "failover_priority": 1}
|
||||
mock_get.side_effect = [consistent_false] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
consistent_true = {"nofailover": True, "failover_priority": 0}
|
||||
mock_get.side_effect = [consistent_true] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
for consistent_state in (
|
||||
{"nofailover": False, "failover_priority": 1},
|
||||
{"nofailover": True, "failover_priority": 0},
|
||||
{"nofailover": "False", "failover_priority": 0}
|
||||
):
|
||||
mock_get.side_effect = [consistent_state] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
# Providing both inconsistently should log a warning
|
||||
inconsistent_false = {"nofailover": False, "failover_priority": 0}
|
||||
mock_get.side_effect = [inconsistent_false] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
|
||||
+ ' Defaulting to nofailover: %s',
|
||||
False,
|
||||
0,
|
||||
False
|
||||
)
|
||||
mock_logger.warning.reset_mock()
|
||||
inconsistent_true = {"nofailover": True, "failover_priority": 1}
|
||||
mock_get.side_effect = [inconsistent_true] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
|
||||
+ ' Defaulting to nofailover: %s',
|
||||
True,
|
||||
1,
|
||||
True
|
||||
)
|
||||
for inconsistent_state in (
|
||||
{"nofailover": False, "failover_priority": 0},
|
||||
{"nofailover": True, "failover_priority": 1},
|
||||
{"nofailover": "False", "failover_priority": 1},
|
||||
{"nofailover": "", "failover_priority": 0}
|
||||
):
|
||||
mock_get.side_effect = [inconsistent_state] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
|
||||
+ ' Defaulting to nofailover: %s',
|
||||
inconsistent_state['nofailover'],
|
||||
inconsistent_state['failover_priority'],
|
||||
inconsistent_state['nofailover'])
|
||||
mock_logger.warning.reset_mock()
|
||||
|
||||
def test__process_postgresql_parameters(self):
|
||||
expected_params = {
|
||||
|
||||
+14
-8
@@ -3,8 +3,10 @@ import unittest
|
||||
|
||||
from consul import ConsulException, NotFound
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
|
||||
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession, RetryFailedError
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
from . import SleepException
|
||||
|
||||
|
||||
@@ -91,13 +93,17 @@ class TestConsul(unittest.TestCase):
|
||||
@patch.object(consul.Consul.KV, 'get', kv_get)
|
||||
@patch.object(consul.Consul.KV, 'delete', Mock())
|
||||
def setUp(self):
|
||||
Consul({'ttl': 30, 'scope': 't', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
||||
'verify': 'on', 'key': 'foo', 'cert': 'bar', 'cacert': 'buz', 'token': 'asd', 'dc': 'dc1',
|
||||
'register_service': True})
|
||||
Consul({'ttl': 30, 'scope': 't_', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
||||
'verify': 'on', 'cert': 'bar', 'cacert': 'buz', 'register_service': True})
|
||||
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10,
|
||||
'register_service': True, 'service_check_tls_server_name': True})
|
||||
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 't', 'name': 'p', 'retry_timeout': 10,
|
||||
'consul': {'url': 'https://l:1', 'verify': 'on',
|
||||
'key': 'foo', 'cert': 'bar', 'cacert': 'buz',
|
||||
'token': 'asd', 'dc': 'dc1', 'register_service': True}}), Consul)
|
||||
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 't_', 'name': 'p', 'retry_timeout': 10,
|
||||
'consul': {'url': 'https://l:1', 'verify': 'on',
|
||||
'cert': 'bar', 'cacert': 'buz', 'register_service': True}}), Consul)
|
||||
self.c = get_dcs({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'retry_timeout': 10,
|
||||
'consul': {'host': 'localhost:1', 'register_service': True,
|
||||
'service_check_tls_server_name': True}})
|
||||
self.assertIsInstance(self.c, Consul)
|
||||
self.c._base_path = 'service/good'
|
||||
self.c.get_cluster()
|
||||
|
||||
@@ -130,7 +136,7 @@ class TestConsul(unittest.TestCase):
|
||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||
|
||||
def test__get_citus_cluster(self):
|
||||
self.c._citus_group = '0'
|
||||
self.c._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
cluster = self.c.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||
|
||||
+19
-10
@@ -12,6 +12,7 @@ from patroni.ctl import ctl, load_config, output_members, get_dcs, parse_dcs, \
|
||||
get_all_members, get_any_member, get_cursor, query_member, PatroniCtlException, apply_config_changes, \
|
||||
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH, PatronictlPrettyTable
|
||||
from patroni.dcs import Cluster, Failover
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
from patroni.psycopg import OperationalError
|
||||
from patroni.utils import tzutc
|
||||
from prettytable import PrettyTable, ALL
|
||||
@@ -69,7 +70,7 @@ class TestCtl(unittest.TestCase):
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
def test_get_cursor(self):
|
||||
with click.Context(click.Command('query')) as ctx:
|
||||
ctx.obj = {'__config': {}}
|
||||
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
|
||||
for role in self.TEST_ROLES:
|
||||
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), None, {}, role=role))
|
||||
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {}, role=role))
|
||||
@@ -107,7 +108,7 @@ class TestCtl(unittest.TestCase):
|
||||
|
||||
def test_output_members(self):
|
||||
with click.Context(click.Command('list')) as ctx:
|
||||
ctx.obj = {'__config': {}}
|
||||
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
|
||||
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
|
||||
cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at))
|
||||
del cluster.members[1].data['conn_url']
|
||||
@@ -157,7 +158,8 @@ class TestCtl(unittest.TestCase):
|
||||
# Target and source are equal
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nleader\n\ny')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Switchover target and source are the same', result.output)
|
||||
self.assertIn("Candidate ['other']", result.output)
|
||||
self.assertIn('Member leader is already the leader of cluster dummy', result.output)
|
||||
|
||||
# Candidate is not a member of the cluster
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nReality\n\ny')
|
||||
@@ -220,29 +222,36 @@ class TestCtl(unittest.TestCase):
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='0\n')
|
||||
self.assertIn('Failover could be performed only to a specific candidate', result.output)
|
||||
|
||||
# Candidate is the same as the leader
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy', '--group', '0'], input='leader\n')
|
||||
self.assertIn("Candidate ['other']", result.output)
|
||||
self.assertIn('Member leader is already the leader of cluster dummy', result.output)
|
||||
|
||||
# Temp test to check a fallback to switchover if leader is specified
|
||||
with patch('patroni.ctl._do_failover_or_switchover') as failover_func_mock:
|
||||
result = self.runner.invoke(ctl, ['failover', '--leader', 'leader', 'dummy'], input='0\n')
|
||||
self.assertIn('Supplying a leader name using this command is deprecated', result.output)
|
||||
failover_func_mock.assert_called_once_with('switchover', 'dummy', None, 'leader', None, False)
|
||||
|
||||
# Failover to an async member in sync mode (confirm)
|
||||
cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||
cluster.members.append(Member(0, 'async', 28, {'api_url': 'http://127.0.0.1:8012/patroni'}))
|
||||
cluster.config.data['synchronous_mode'] = True
|
||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)):
|
||||
# Failover to an async member in sync mode (confirm)
|
||||
result = self.runner.invoke(ctl,
|
||||
['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='y\ny')
|
||||
self.assertIn('Are you sure you want to failover to the asynchronous node async', result.output)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
# Failover to an async member in sync mode (abort)
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='N')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
# Failover to an async member in sync mode (abort)
|
||||
result = self.runner.invoke(ctl, ['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='N')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Aborting failover', result.output)
|
||||
|
||||
@patch('patroni.dynamic_loader.iter_modules', Mock(return_value=['patroni.dcs.dummy', 'patroni.dcs.etcd']))
|
||||
def test_get_dcs(self):
|
||||
with click.Context(click.Command('list')) as ctx:
|
||||
ctx.obj = {'__config': {'dummy': {}}}
|
||||
ctx.obj = {'__config': {'dummy': {}}, '__mpp': get_mpp({})}
|
||||
self.assertRaises(PatroniCtlException, get_dcs, 'dummy', 0)
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@@ -431,7 +440,7 @@ class TestCtl(unittest.TestCase):
|
||||
|
||||
def test_get_any_member(self):
|
||||
with click.Context(click.Command('list')) as ctx:
|
||||
ctx.obj = {'__config': {}}
|
||||
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
|
||||
for role in self.TEST_ROLES:
|
||||
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), None, role=role))
|
||||
|
||||
@@ -440,7 +449,7 @@ class TestCtl(unittest.TestCase):
|
||||
|
||||
def test_get_all_members(self):
|
||||
with click.Context(click.Command('list')) as ctx:
|
||||
ctx.obj = {'__config': {}}
|
||||
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
|
||||
for role in self.TEST_ROLES:
|
||||
self.assertEqual(list(get_all_members(get_cluster_initialized_without_leader(), None, role=role)), [])
|
||||
|
||||
|
||||
+7
-4
@@ -5,8 +5,10 @@ import unittest
|
||||
|
||||
from dns.exception import DNSException
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.dcs.etcd import AbstractDCS, EtcdClient, Cluster, Etcd, EtcdError, DnsCachingResolver
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
from patroni.utils import Retry
|
||||
from urllib3.exceptions import ReadTimeoutError
|
||||
|
||||
@@ -138,8 +140,9 @@ class TestClient(unittest.TestCase):
|
||||
@patch.object(EtcdClient, '_get_machines_list',
|
||||
Mock(return_value=['http://localhost:2379', 'http://localhost:4001']))
|
||||
def setUp(self):
|
||||
self.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 3,
|
||||
'srv': 'test', 'scope': 'test', 'name': 'foo'})
|
||||
self.etcd = get_dcs({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 3,
|
||||
'etcd': {'srv': 'test'}, 'scope': 'test', 'name': 'foo'})
|
||||
self.assertIsInstance(self.etcd, Etcd)
|
||||
self.client = self.etcd._client
|
||||
self.client.http.request = http_request
|
||||
self.client.http.request_encode_body = http_request
|
||||
@@ -235,7 +238,7 @@ class TestEtcd(unittest.TestCase):
|
||||
Mock(return_value=['http://localhost:2379', 'http://localhost:4001']))
|
||||
def setUp(self):
|
||||
self.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
|
||||
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'})
|
||||
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'}, get_mpp({}))
|
||||
|
||||
def test_base_path(self):
|
||||
self.assertEqual(self.etcd._base_path, '/patroni/test')
|
||||
@@ -270,7 +273,7 @@ class TestEtcd(unittest.TestCase):
|
||||
self.assertRaises(EtcdError, self.etcd.get_cluster)
|
||||
|
||||
def test__get_citus_cluster(self):
|
||||
self.etcd._citus_group = '0'
|
||||
self.etcd._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
cluster = self.etcd.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||
|
||||
+6
-4
@@ -4,10 +4,12 @@ import unittest
|
||||
import urllib3
|
||||
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.dcs.etcd import DnsCachingResolver
|
||||
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Client, \
|
||||
Etcd3Error, Etcd3ClientError, ReAuthenticateMode, RetryFailedError, InvalidAuthToken, Unavailable, \
|
||||
Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, AuthOldRevision, base64_encode
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
from threading import Thread
|
||||
|
||||
from . import SleepException, MockResponse
|
||||
@@ -80,9 +82,9 @@ class BaseTestEtcd3(unittest.TestCase):
|
||||
@patch.object(Thread, 'start', Mock())
|
||||
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
|
||||
def setUp(self):
|
||||
self.etcd3 = Etcd3({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
|
||||
'host': 'localhost:2378', 'scope': 'test', 'name': 'foo',
|
||||
'username': 'etcduser', 'password': 'etcdpassword'})
|
||||
self.etcd3 = get_dcs({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10, 'name': 'foo', 'scope': 'test',
|
||||
'etcd3': {'host': 'localhost:2378', 'username': 'etcduser', 'password': 'etcdpassword'}})
|
||||
self.assertIsInstance(self.etcd3, Etcd3)
|
||||
self.client = self.etcd3._client
|
||||
self.kv_cache = self.client._kv_cache
|
||||
|
||||
@@ -236,7 +238,7 @@ class TestEtcd3(BaseTestEtcd3):
|
||||
self.assertRaises(Etcd3Error, self.etcd3.get_cluster)
|
||||
|
||||
def test__get_citus_cluster(self):
|
||||
self.etcd3._citus_group = '0'
|
||||
self.etcd3._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
cluster = self.etcd3.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||
|
||||
@@ -2,6 +2,7 @@ import unittest
|
||||
import urllib3
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.dcs.exhibitor import ExhibitorEnsembleProvider, Exhibitor
|
||||
from patroni.dcs.zookeeper import ZooKeeperError
|
||||
|
||||
@@ -26,8 +27,9 @@ class TestExhibitor(unittest.TestCase):
|
||||
status=200, body=b'{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}')))
|
||||
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
|
||||
def setUp(self):
|
||||
self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test',
|
||||
'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
|
||||
self.e = get_dcs({'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181},
|
||||
'scope': 'test', 'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
|
||||
self.assertIsInstance(self.e, Exhibitor)
|
||||
|
||||
@patch.object(ExhibitorEnsembleProvider, 'poll', Mock(return_value=True))
|
||||
@patch.object(MockKazooClient, 'get_children', Mock(side_effect=Exception))
|
||||
|
||||
+16
-11
@@ -198,7 +198,7 @@ def run_async(self, func, args=()):
|
||||
@patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=False))
|
||||
@patch('patroni.async_executor.AsyncExecutor.run_async', run_async)
|
||||
@patch('patroni.postgresql.rewind.Thread', Mock())
|
||||
@patch('patroni.postgresql.citus.CitusHandler.start', Mock())
|
||||
@patch('patroni.postgresql.mpp.citus.CitusHandler.start', Mock())
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('time.sleep', Mock())
|
||||
class TestHa(PostgresInit):
|
||||
@@ -595,8 +595,8 @@ class TestHa(PostgresInit):
|
||||
self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock')
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.citus.connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.citus.quote_ident', Mock())
|
||||
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
|
||||
@patch.object(Postgresql, 'connection', Mock(return_value=None))
|
||||
def test_bootstrap_initialized_new_cluster(self):
|
||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||
@@ -617,8 +617,8 @@ class TestHa(PostgresInit):
|
||||
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.citus.connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.citus.quote_ident', Mock())
|
||||
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
|
||||
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
|
||||
@patch.object(Postgresql, 'connection', Mock(return_value=None))
|
||||
def test_bootstrap_release_initialize_key_on_watchdog_failure(self):
|
||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||
@@ -661,7 +661,7 @@ class TestHa(PostgresInit):
|
||||
@patch.object(ConfigHandler, 'replace_pg_hba', Mock())
|
||||
@patch.object(ConfigHandler, 'replace_pg_ident', Mock())
|
||||
@patch.object(PostmasterProcess, 'start', Mock(return_value=MockPostmaster()))
|
||||
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
||||
def test_worker_restart(self):
|
||||
self.ha.has_lock = true
|
||||
self.ha.patroni.request = Mock()
|
||||
@@ -696,7 +696,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.is_paused = true
|
||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: restart in progress')
|
||||
|
||||
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
||||
def test_manual_failover_from_leader(self):
|
||||
self.ha.has_lock = true # I am the leader
|
||||
|
||||
@@ -735,7 +735,7 @@ class TestHa(PostgresInit):
|
||||
('Member %s exceeds maximum replication lag', 'b'))
|
||||
self.ha.cluster.members.pop()
|
||||
|
||||
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
||||
def test_manual_switchover_from_leader(self):
|
||||
self.ha.has_lock = true # I am the leader
|
||||
|
||||
@@ -776,7 +776,7 @@ class TestHa(PostgresInit):
|
||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
||||
self.assertEqual(mock_info.call_args_list[0][0], ('Member %s exceeds maximum replication lag', 'leader'))
|
||||
|
||||
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
||||
def test_scheduled_switchover_from_leader(self):
|
||||
self.ha.has_lock = true # I am the leader
|
||||
|
||||
@@ -1552,7 +1552,7 @@ class TestHa(PostgresInit):
|
||||
self.ha.is_failover_possible = true
|
||||
self.ha.shutdown()
|
||||
|
||||
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
||||
def test_shutdown_citus_worker(self):
|
||||
self.ha.is_leader = true
|
||||
self.p.is_running = Mock(side_effect=[Mock(), False])
|
||||
@@ -1593,6 +1593,11 @@ class TestHa(PostgresInit):
|
||||
self.p.is_primary = false
|
||||
self.ha.run_cycle()
|
||||
exit_mock.assert_called_once_with(1)
|
||||
self.p.set_role('replica')
|
||||
self.ha.dcs.initialize = Mock()
|
||||
with patch.object(Postgresql, 'cb_called', PropertyMock(return_value=True)):
|
||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
||||
self.ha.dcs.initialize.assert_not_called()
|
||||
|
||||
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
||||
def test_after_pause(self):
|
||||
@@ -1659,7 +1664,7 @@ class TestHa(PostgresInit):
|
||||
self.assertRaises(DCSError, self.ha.acquire_lock)
|
||||
self.assertFalse(self.ha.acquire_lock())
|
||||
|
||||
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
||||
def test_notify_citus_coordinator(self):
|
||||
self.ha.patroni.request = Mock()
|
||||
self.ha.notify_citus_coordinator('before_demote')
|
||||
|
||||
@@ -8,9 +8,11 @@ import unittest
|
||||
import urllib3
|
||||
|
||||
from mock import Mock, PropertyMock, mock_open, patch
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.dcs.kubernetes import Cluster, k8s_client, k8s_config, K8sConfig, K8sConnectionFailed, \
|
||||
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException, \
|
||||
Retry, RetryFailedError, SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
from threading import Thread
|
||||
from . import MockResponse, SleepException
|
||||
|
||||
@@ -225,11 +227,12 @@ class BaseTestKubernetes(unittest.TestCase):
|
||||
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_pod', mock_list_namespaced_pod, create=True)
|
||||
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
|
||||
def setUp(self, config=None):
|
||||
config = config or {}
|
||||
config.update(ttl=30, scope='test', name='p-0', loop_wait=10, group=0,
|
||||
retry_timeout=10, labels={'f': 'b'}, bypass_api_service=True)
|
||||
self.k = Kubernetes(config)
|
||||
self.k._citus_group = None
|
||||
config = {'ttl': 30, 'scope': 'test', 'name': 'p-0', 'loop_wait': 10, 'retry_timeout': 10,
|
||||
'kubernetes': {'labels': {'f': 'b'}, 'bypass_api_service': True, **(config or {})},
|
||||
'citus': {'group': 0, 'database': 'postgres'}}
|
||||
self.k = get_dcs(config)
|
||||
self.assertIsInstance(self.k, Kubernetes)
|
||||
self.k._mpp = get_mpp({})
|
||||
self.assertRaises(AttributeError, self.k._pods._build_cache)
|
||||
self.k._pods._is_ready = True
|
||||
self.assertRaises(TypeError, self.k._kinds._build_cache)
|
||||
@@ -254,7 +257,7 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
|
||||
self.assertRaises(KubernetesError, self.k.get_cluster)
|
||||
|
||||
def test__get_citus_cluster(self):
|
||||
self.k._citus_group = '0'
|
||||
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
cluster = self.k.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||
@@ -466,7 +469,7 @@ class TestCacheBuilder(BaseTestKubernetes):
|
||||
@patch('patroni.dcs.kubernetes.ObjectCache._watch', mock_watch)
|
||||
@patch.object(urllib3.HTTPResponse, 'read_chunked')
|
||||
def test__build_cache(self, mock_read_chunked):
|
||||
self.k._citus_group = '0'
|
||||
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
mock_read_chunked.return_value = [json.dumps(
|
||||
{'type': 'MODIFIED', 'object': {'metadata': {
|
||||
'name': self.k.config_path, 'resourceVersion': '2', 'annotations': {self.k._CONFIG: 'foo'}}}}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from typing import Any
|
||||
from patroni.exceptions import PatroniException
|
||||
from patroni.postgresql.mpp import AbstractMPP, get_mpp, Null
|
||||
|
||||
from . import BaseTestPostgresql
|
||||
from .test_ha import get_cluster_initialized_with_leader
|
||||
|
||||
|
||||
class TestMPP(BaseTestPostgresql):
|
||||
|
||||
def setUp(self):
|
||||
super(TestMPP, self).setUp()
|
||||
self.cluster = get_cluster_initialized_with_leader()
|
||||
|
||||
def test_get_handler_impl_exception(self):
|
||||
class DummyMPP(AbstractMPP):
|
||||
def __init__(self) -> None:
|
||||
super().__init__({})
|
||||
|
||||
@staticmethod
|
||||
def validate_config(config: Any) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def group(self) -> None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def coordinator_group_id(self) -> None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "dummy"
|
||||
|
||||
mpp = DummyMPP()
|
||||
self.assertRaises(PatroniException, mpp.get_handler_impl, self.p)
|
||||
|
||||
def test_null_handler(self):
|
||||
config = {}
|
||||
mpp = get_mpp(config)
|
||||
self.assertIsInstance(mpp, Null)
|
||||
self.assertIsNone(mpp.group)
|
||||
self.assertTrue(mpp.validate_config(config))
|
||||
nullHandler = mpp.get_handler_impl(self.p)
|
||||
self.assertIsNone(nullHandler.handle_event(self.cluster, {}))
|
||||
self.assertIsNone(nullHandler.sync_meta_data(self.cluster))
|
||||
self.assertIsNone(nullHandler.on_demote())
|
||||
self.assertIsNone(nullHandler.schedule_cache_rebuild())
|
||||
self.assertIsNone(nullHandler.bootstrap())
|
||||
self.assertIsNone(nullHandler.adjust_postgres_gucs({}))
|
||||
self.assertFalse(nullHandler.ignore_replication_slot({}))
|
||||
@@ -175,6 +175,20 @@ class TestPatroni(unittest.TestCase):
|
||||
self.p.next_run = time.time() - self.p.dcs.loop_wait - 1
|
||||
self.p.schedule_next_run()
|
||||
|
||||
def test__filter_tags(self):
|
||||
tags = {'noloadbalance': False, 'clonefrom': False, 'nosync': False, 'smth': 'random'}
|
||||
self.assertEqual(self.p._filter_tags(tags), {'smth': 'random'})
|
||||
|
||||
tags['clonefrom'] = True
|
||||
tags['smth'] = False
|
||||
self.assertEqual(self.p._filter_tags(tags), {'clonefrom': True, 'smth': False})
|
||||
|
||||
tags = {'nofailover': False, 'failover_priority': 0}
|
||||
self.assertEqual(self.p._filter_tags(tags), tags)
|
||||
|
||||
tags = {'nofailover': True, 'failover_priority': 1}
|
||||
self.assertEqual(self.p._filter_tags(tags), tags)
|
||||
|
||||
def test_noloadbalance(self):
|
||||
self.p.tags['noloadbalance'] = True
|
||||
self.assertTrue(self.p.noloadbalance)
|
||||
@@ -186,9 +200,11 @@ class TestPatroni(unittest.TestCase):
|
||||
# Setting `nofailover: True` has precedence
|
||||
(True, 0, True),
|
||||
(True, 1, True),
|
||||
('False', 1, True), # because we use bool() for the value
|
||||
# Similarly, setting `nofailover: False` has precedence
|
||||
(False, 0, False),
|
||||
(False, 1, False),
|
||||
('', 0, False),
|
||||
# Only when we have `nofailover: None` should we got based on priority
|
||||
(None, 0, True),
|
||||
(None, 1, False),
|
||||
|
||||
+95
-21
@@ -5,6 +5,7 @@ import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from copy import deepcopy
|
||||
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||
|
||||
import patroni.psycopg as psycopg
|
||||
@@ -25,7 +26,8 @@ from patroni.postgresql.validator import (ValidatorFactoryNoType, ValidatorFacto
|
||||
from patroni.utils import RetryFailedError
|
||||
from threading import Thread, current_thread
|
||||
|
||||
from . import BaseTestPostgresql, MockCursor, MockPostmaster, psycopg_connect, mock_available_gucs
|
||||
from . import (BaseTestPostgresql, MockCursor, MockPostmaster, psycopg_connect, mock_available_gucs,
|
||||
GET_PG_SETTINGS_RESULT)
|
||||
|
||||
|
||||
mtime_ret = {}
|
||||
@@ -559,31 +561,103 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
def test_reload_config(self):
|
||||
parameters = self._PARAMETERS.copy()
|
||||
parameters.pop('f.oo')
|
||||
parameters['wal_buffers'] = '512'
|
||||
config = {'pg_hba': [''], 'pg_ident': [''], 'use_unix_socket': True, 'use_unix_socket_repl': True,
|
||||
'authentication': {},
|
||||
'retry_timeout': 10, 'listen': '*', 'krbsrvname': 'postgres', 'parameters': parameters}
|
||||
@patch('patroni.postgresql.config.logger.info')
|
||||
@patch('patroni.postgresql.config.logger.warning')
|
||||
def test_reload_config(self, mock_warning, mock_info):
|
||||
config = deepcopy(self.p.config._config)
|
||||
|
||||
# Nothing changed
|
||||
self.p.reload_config(config)
|
||||
parameters['b.ar'] = 'bar'
|
||||
with patch.object(MockCursor, 'fetchall',
|
||||
Mock(side_effect=[[('wal_block_size', '8191', None, 'integer', 'internal'),
|
||||
('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
|
||||
('shared_buffers', '16384', '8kB', 'integer', 'postmaster'),
|
||||
('wal_buffers', '-1', '8kB', 'integer', 'postmaster'),
|
||||
('port', '5433', None, 'integer', 'postmaster')], Exception])):
|
||||
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
|
||||
mock_warning.assert_not_called()
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Handle wal_buffers
|
||||
self.p.config._config['parameters']['wal_buffers'] = '512'
|
||||
self.p.reload_config(config)
|
||||
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
|
||||
mock_info.reset_mock()
|
||||
config = deepcopy(self.p.config._config)
|
||||
|
||||
# hba/ident_changed
|
||||
config['pg_hba'] = ['']
|
||||
config['pg_ident'] = ['']
|
||||
self.p.reload_config(config)
|
||||
mock_info.assert_called_once_with('Reloading PostgreSQL configuration.')
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Postmaster parameter change (pending_restart)
|
||||
init_max_worker_processes = config['parameters']['max_worker_processes']
|
||||
config['parameters']['max_worker_processes'] *= 2
|
||||
with patch('patroni.postgresql.Postgresql._query', Mock(side_effect=[GET_PG_SETTINGS_RESULT, [(1,)]])):
|
||||
self.p.reload_config(config)
|
||||
parameters['autovacuum'] = 'on'
|
||||
self.assertEqual(mock_info.call_args_list[0][0], ('Changed %s from %s to %s (restart might be required)',
|
||||
'max_worker_processes', str(init_max_worker_processes),
|
||||
config['parameters']['max_worker_processes']))
|
||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||
self.assertEqual(self.p.pending_restart, True)
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Reset to the initial value without restart
|
||||
config['parameters']['max_worker_processes'] = init_max_worker_processes
|
||||
self.p.reload_config(config)
|
||||
parameters['autovacuum'] = 'off'
|
||||
parameters.pop('search_path')
|
||||
config['listen'] = '*:5433'
|
||||
self.assertEqual(mock_info.call_args_list[0][0], ('Changed %s from %s to %s', 'max_worker_processes',
|
||||
init_max_worker_processes * 2,
|
||||
str(config['parameters']['max_worker_processes'])))
|
||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
# User-defined parameter changed (removed)
|
||||
config['parameters'].pop('f.oo')
|
||||
self.p.reload_config(config)
|
||||
parameters['unix_socket_directories'] = '.'
|
||||
self.assertEqual(mock_info.call_args_list[0][0], ('Changed %s from %s to %s', 'f.oo', 'bar', None))
|
||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Non-postmaster parameter change
|
||||
config['parameters']['autovacuum'] = 'off'
|
||||
self.p.reload_config(config)
|
||||
self.p.config.resolve_connection_addresses()
|
||||
self.assertEqual(mock_info.call_args_list[0][0], ("Changed %s from %s to %s", 'autovacuum', 'on', 'off'))
|
||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
|
||||
config['parameters']['autovacuum'] = 'on'
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Remove invalid parameter
|
||||
config['parameters']['invalid'] = 'value'
|
||||
self.p.reload_config(config)
|
||||
self.assertEqual(mock_warning.call_args_list[0][0],
|
||||
('Removing invalid parameter `%s` from postgresql.parameters', 'invalid'))
|
||||
config['parameters'].pop('invalid')
|
||||
|
||||
mock_warning.reset_mock()
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Non-empty result (outside changes) and exception while querying pending_restart parameters
|
||||
with patch('patroni.postgresql.Postgresql._query',
|
||||
Mock(side_effect=[GET_PG_SETTINGS_RESULT, [(1,)], GET_PG_SETTINGS_RESULT, Exception])):
|
||||
self.p.reload_config(config, True)
|
||||
self.assertEqual(mock_info.call_args_list[0][0], ('Reloading PostgreSQL configuration.',))
|
||||
self.assertEqual(self.p.pending_restart, True)
|
||||
|
||||
# Invalid values, just to increase silly coverage in postgresql.validator.
|
||||
# One day we will have proper tests there.
|
||||
config['parameters']['autovacuum'] = 'of' # Bool.transform()
|
||||
config['parameters']['vacuum_cost_limit'] = 'smth' # Number.transform()
|
||||
self.p.reload_config(config, True)
|
||||
self.assertEqual(mock_warning.call_args_list[-1][0][0], 'Exception %r when running query')
|
||||
|
||||
def test_resolve_connection_addresses(self):
|
||||
self.p.config._config['use_unix_socket'] = self.p.config._config['use_unix_socket_repl'] = True
|
||||
|
||||
+12
-9
@@ -4,8 +4,10 @@ import tempfile
|
||||
import time
|
||||
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.dcs.raft import Cluster, DynMemberSyncObj, KVStoreTTL, \
|
||||
Raft, RaftError, SyncObjUtility, TCPTransport, _TCPTransport
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
from pysyncobj import SyncObjConf, FAIL_REASON
|
||||
|
||||
|
||||
@@ -128,9 +130,10 @@ class TestRaft(unittest.TestCase):
|
||||
_TMP = tempfile.gettempdir()
|
||||
|
||||
def test_raft(self):
|
||||
raft = Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'self_addr': '127.0.0.1:1234',
|
||||
'retry_timeout': 10, 'data_dir': self._TMP,
|
||||
'database': 'citus', 'group': 0})
|
||||
raft = get_dcs({'ttl': 30, 'scope': 'test', 'name': 'pg', 'retry_timeout': 10,
|
||||
'raft': {'self_addr': '127.0.0.1:1234', 'data_dir': self._TMP},
|
||||
'citus': {'group': 0, 'database': 'postgres'}})
|
||||
self.assertIsInstance(raft, Raft)
|
||||
raft.reload_config({'retry_timeout': 20, 'ttl': 60, 'loop_wait': 10})
|
||||
self.assertTrue(raft._sync_obj.set(raft.members_path + 'legacy', '{"version":"2.0.0"}'))
|
||||
self.assertTrue(raft.touch_member(''))
|
||||
@@ -139,9 +142,9 @@ class TestRaft(unittest.TestCase):
|
||||
self.assertTrue(raft.set_config_value('{}'))
|
||||
self.assertTrue(raft.write_sync_state('foo', 'bar', 0))
|
||||
self.assertFalse(raft.write_sync_state('foo', 'bar', 0, 1))
|
||||
raft._citus_group = '1'
|
||||
raft._mpp = get_mpp({'citus': {'group': 1, 'database': 'postgres'}})
|
||||
self.assertTrue(raft.manual_failover('foo', 'bar'))
|
||||
raft._citus_group = '0'
|
||||
raft._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
self.assertTrue(raft.take_leader())
|
||||
cluster = raft.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
@@ -157,9 +160,9 @@ class TestRaft(unittest.TestCase):
|
||||
self.assertTrue(raft.delete_sync_state())
|
||||
self.assertTrue(raft.set_history_value(''))
|
||||
self.assertTrue(raft.delete_cluster())
|
||||
raft._citus_group = '1'
|
||||
raft._mpp = get_mpp({'citus': {'group': 1, 'database': 'postgres'}})
|
||||
self.assertTrue(raft.delete_cluster())
|
||||
raft._citus_group = None
|
||||
raft._mpp = get_mpp({})
|
||||
raft.get_cluster()
|
||||
raft.watch(None, 0.001)
|
||||
raft._sync_obj.destroy()
|
||||
@@ -175,5 +178,5 @@ class TestRaft(unittest.TestCase):
|
||||
def test_init(self, mock_event, mock_kvstore):
|
||||
mock_kvstore.return_value.applied_local_log = False
|
||||
mock_event.return_value.is_set.side_effect = [False, True]
|
||||
self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
|
||||
'self_addr': '1', 'data_dir': self._TMP}))
|
||||
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
|
||||
'raft': {'self_addr': '1', 'data_dir': self._TMP}}), Raft)
|
||||
|
||||
@@ -13,6 +13,20 @@ available_dcs = [m.split(".")[-1] for m in dcs_modules()]
|
||||
config = {
|
||||
"name": "string",
|
||||
"scope": "string",
|
||||
"log": {
|
||||
"level": "DEBUG",
|
||||
"traceback_level": "DEBUG",
|
||||
"format": "%(asctime)s %(levelname)s: %(message)s",
|
||||
"dateformat": "%Y-%m-%d %H:%M:%S",
|
||||
"max_queue_size": 100,
|
||||
"dir": "/tmp",
|
||||
"file_num": 10,
|
||||
"file_size": 1000000,
|
||||
"loggers": {
|
||||
"patroni.postmaster": "WARNING",
|
||||
"urllib3": "DEBUG"
|
||||
}
|
||||
},
|
||||
"restapi": {
|
||||
"listen": "127.0.0.2:800",
|
||||
"connect_address": "127.0.0.2:800",
|
||||
|
||||
@@ -7,8 +7,10 @@ from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
|
||||
from kazoo.retry import RetryFailedError
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.dcs.zookeeper import Cluster, PatroniKazooClient, \
|
||||
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
|
||||
|
||||
class MockKazooClient(Mock):
|
||||
@@ -148,9 +150,9 @@ class TestZooKeeper(unittest.TestCase):
|
||||
|
||||
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
|
||||
def setUp(self):
|
||||
self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test',
|
||||
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
|
||||
'set_acls': {'CN=principal2': ['ALL']}})
|
||||
self.zk = get_dcs({'scope': 'test', 'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
|
||||
'zookeeper': {'hosts': ['localhost:2181'], 'set_acls': {'CN=principal2': ['ALL']}}})
|
||||
self.assertIsInstance(self.zk, ZooKeeper)
|
||||
|
||||
def test_reload_config(self):
|
||||
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 10})
|
||||
@@ -177,7 +179,7 @@ class TestZooKeeper(unittest.TestCase):
|
||||
self.assertEqual(cluster.last_lsn, 500)
|
||||
|
||||
def test__get_citus_cluster(self):
|
||||
self.zk._citus_group = '0'
|
||||
self.zk._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
for _ in range(0, 2):
|
||||
cluster = self.zk.get_cluster()
|
||||
self.assertIsInstance(cluster, Cluster)
|
||||
|
||||
Reference in New Issue
Block a user