mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 15:40:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820cc404b9 | ||
|
|
e131065d74 | ||
|
|
bdd02324b4 | ||
|
|
7adfc0dbe7 | ||
|
|
f6943a859d | ||
|
|
e532f9dc38 | ||
|
|
688c85389c | ||
|
|
5c4ee30dae | ||
|
|
266cdc4810 | ||
|
|
2ac1efea54 | ||
|
|
5d8c2fb559 | ||
|
|
4e5b2ee249 | ||
|
|
3390ee9dea | ||
|
|
71ccf91e36 | ||
|
|
8acefefc42 | ||
|
|
dd548c4964 | ||
|
|
bcfd8438a5 | ||
|
|
5c3e1a693e | ||
|
|
206ee91b07 | ||
|
|
c1ee99d81d |
@@ -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.338
|
||||
version: 1.1.347
|
||||
|
||||
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
|
||||
|
||||
@@ -14,10 +14,18 @@ Global/Universal
|
||||
|
||||
Log
|
||||
---
|
||||
- **PATRONI\_LOG\_TYPE**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
|
||||
- **PATRONI\_LOG\_LEVEL**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
|
||||
- **PATRONI\_LOG\_TRACEBACK\_LEVEL**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **PATRONI\_LOG\_LEVEL=DEBUG**.
|
||||
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
|
||||
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. If the log type is **plain**, the log format should be a string.
|
||||
Refer to `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
|
||||
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
|
||||
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
|
||||
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
|
||||
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
|
||||
Default value is **%(asctime)s %(levelname)s: %(message)s**
|
||||
- **PATRONI\_LOG\_DATEFORMAT**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
|
||||
- **PATRONI\_LOG\_STATIC\_FIELDS**: add additional fields to the log. This option is only available when the log type is set to **json**. Example ``PATRONI_LOG_STATIC_FIELDS="{app: patroni}"``
|
||||
- **PATRONI\_LOG\_MAX\_QUEUE\_SIZE**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
|
||||
- **PATRONI\_LOG\_DIR**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this env variable, the application will retain 4 25MB logs by default. You can tune those retention values with `PATRONI_LOG_FILE_NUM` and `PATRONI_LOG_FILE_SIZE` (see below).
|
||||
- **PATRONI\_LOG\_FILE\_NUM**: The number of application logs to retain.
|
||||
|
||||
@@ -60,6 +60,8 @@ raft
|
||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||
aws
|
||||
`boto3` in order to use AWS callbacks
|
||||
jsonlogger
|
||||
`python-json-logger` module in order to enable :ref:`logging <log_settings>` in json format
|
||||
all
|
||||
all of the above (except psycopg family)
|
||||
psycopg
|
||||
|
||||
@@ -3,6 +3,56 @@
|
||||
Release notes
|
||||
=============
|
||||
|
||||
Version 3.2.2
|
||||
-------------
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Don't let replica restore initialize key when DCS was wiped (Alexander Kukushkin)
|
||||
|
||||
It was happening in the method where Patroni was supposed to take over a standalone PG cluster.
|
||||
|
||||
- Use consistent read when fetching just updated sync key from Consul (Alexander Kukushkin)
|
||||
|
||||
Consul doesn't provide any interface to immediately get ``ModifyIndex`` for the key that we just updated, therefore we have to perform an explicit read operation. Since stale reads are allowed by default, we sometimes used to get an outdated version of the key.
|
||||
|
||||
- Reload Postgres config if a parameter that requires restart was reset to the original value (Polina Bungina)
|
||||
|
||||
Previously Patroni wasn't updating the config, but only resetting the ``pending_restart``.
|
||||
|
||||
- Fix erroneous inverted logic of the confirmation prompt message when doing a failover to an async candidate in synchronous mode (Polina Bungina)
|
||||
|
||||
The problem existed only in ``patronictl``.
|
||||
|
||||
- Exclude leader from failover candidates in ``patronictl`` (Polina Bungina)
|
||||
|
||||
If the cluster is healthy, failing over to an existing leader is no-op.
|
||||
|
||||
- Create Citus database and extension idempotently (Alexander Kukushkin, Zhao Junwang)
|
||||
|
||||
It will allow to create them in the ``post_bootstrap`` script in case if there is a need to add some more dependencies to the Citus database.
|
||||
|
||||
- Don't filter our contradictory ``nofailover`` tag (Polina Bungina)
|
||||
|
||||
The configuration ``{nofailover: false, failover_priority: 0}`` set on a node didn't allow it to participate in the race, while it should, because ``nofailover`` tag should take precedence.
|
||||
|
||||
- Fixed PyInstaller frozen issue (Sophia Ruan)
|
||||
|
||||
The ``freeze_support()`` was called after ``argparse`` and as a result, Patroni wasn't able to start Postgres.
|
||||
|
||||
- Fixed bug in the config generator for ``patronictl`` and ``Citus`` configuration (Israel Barth Rubio)
|
||||
|
||||
It prevented ``patronictl`` and ``Citus`` configuration parameters set via environment variables from being written into the generated config.
|
||||
|
||||
- Restore recovery GUCs and some Patroni-managed parameters when joining a running standby (Alexander Kukushkin)
|
||||
|
||||
Patroni was failing to restart Postgres v12 onwards with an error about missing ``port`` in one of the internal structures.
|
||||
|
||||
- Fixes around ``pending_restart`` flag (Polina Bungina)
|
||||
|
||||
Don't expose ``pending_restart`` when in custom bootstrap with ``recovery_target_action = promote`` or when someone changed ``hot_standby`` or ``wal_log_hints`` using for example ``ALTER SYSTEM``.
|
||||
|
||||
|
||||
Version 3.2.1
|
||||
-------------
|
||||
|
||||
|
||||
@@ -11,12 +11,22 @@ Global/Universal
|
||||
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
||||
- **scope**: cluster name
|
||||
|
||||
.. _log_settings:
|
||||
|
||||
Log
|
||||
---
|
||||
- **type**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
|
||||
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
|
||||
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
|
||||
- **format**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
|
||||
- **format**: sets the log formatting string. If the log type is **plain**, the log format should be a string. Refer to
|
||||
`the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
|
||||
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
|
||||
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
|
||||
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
|
||||
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
|
||||
Default value is **%(asctime)s %(levelname)s: %(message)s**
|
||||
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
|
||||
- **static_fields**: add additional fields to the log. This option is only available when the log type is set to **json**.
|
||||
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
|
||||
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
|
||||
- **file\_num**: The number of application logs to retain.
|
||||
@@ -26,6 +36,20 @@ Log
|
||||
- **patroni.postmaster: WARNING**
|
||||
- **urllib3: DEBUG**
|
||||
|
||||
Here is an example of how to config patroni to log in json format.
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
log:
|
||||
type: json
|
||||
format:
|
||||
- message
|
||||
- module
|
||||
- asctime: '@timestamp'
|
||||
- levelname: level
|
||||
static_fields:
|
||||
app: patroni
|
||||
|
||||
.. _bootstrap_settings:
|
||||
|
||||
Bootstrap configuration
|
||||
|
||||
@@ -6,10 +6,9 @@ Feature: priority replication
|
||||
And I configure and start postgres1 with a tag failover_priority 0
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
When I shut down postgres0
|
||||
And I sleep for 5 seconds
|
||||
Then postgres1 role is the secondary after 10 seconds
|
||||
And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres1 patroni log after 5 seconds
|
||||
Given I start postgres0
|
||||
Then postgres1 role is the secondary after 10 seconds
|
||||
When I start postgres0
|
||||
Then postgres0 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check higher failover priority is respected
|
||||
@@ -18,17 +17,23 @@ Feature: priority replication
|
||||
Then replication works from postgres0 to postgres2 after 20 seconds
|
||||
And replication works from postgres0 to postgres3 after 20 seconds
|
||||
When I shut down postgres0
|
||||
And I sleep for 5 seconds
|
||||
Then postgres3 role is the primary after 10 seconds
|
||||
And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds
|
||||
|
||||
Scenario: check conflicting configuration handling
|
||||
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
|
||||
When I issue a GET request to http://127.0.0.1:8010/patroni
|
||||
Then I receive a response tags {'nofailover': True}
|
||||
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
|
||||
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:8009/failover with {"candidate": "postgres1"}
|
||||
Then I receive a response code 200
|
||||
And postgres1 role is the primary after 10 seconds
|
||||
|
||||
@@ -114,7 +114,7 @@ def replication_works(context, primary, replica, time_limit):
|
||||
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
|
||||
|
||||
|
||||
@then('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
|
||||
@step('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
|
||||
def check_patroni_log(context, message_list, level, node, timeout):
|
||||
timeout *= context.timeout_multiplier
|
||||
message_list = json.loads(message_list)
|
||||
|
||||
@@ -128,9 +128,10 @@ 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 set {tag:w} tag in {pg_name:w} config')
|
||||
def add_bool_tag_to_config(context, tag, pg_name):
|
||||
context.pctl.add_tag_to_config(pg_name, tag, True)
|
||||
@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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
+17
-6
@@ -180,6 +180,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
* ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
|
||||
* ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
|
||||
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
|
||||
* ``pending_restart_reason``: dictionary where each key is the parameter that caused "pending restart" flag
|
||||
to be set and the value is a dictionary with the old and the new value.
|
||||
* ``scheduled_restart``: a dictionary with a single key ``schedule``, which is the timestamp for the
|
||||
scheduled restart;
|
||||
* ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
|
||||
@@ -196,8 +198,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
response['tags'] = tags
|
||||
if patroni.postgresql.sysid:
|
||||
response['database_system_identifier'] = patroni.postgresql.sysid
|
||||
if patroni.postgresql.pending_restart:
|
||||
if patroni.postgresql.pending_restart_reason:
|
||||
response['pending_restart'] = True
|
||||
response['pending_restart_reason'] = dict(patroni.postgresql.pending_restart_reason)
|
||||
response['patroni'] = {
|
||||
'version': patroni.version,
|
||||
'scope': patroni.postgresql.scope,
|
||||
@@ -634,7 +637,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_pending_restart gauge")
|
||||
metrics.append("patroni_pending_restart{0} {1}"
|
||||
.format(labels, int(patroni.postgresql.pending_restart)))
|
||||
.format(labels, int(bool(patroni.postgresql.pending_restart_reason))))
|
||||
|
||||
metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
|
||||
metrics.append("# TYPE patroni_is_paused gauge")
|
||||
@@ -1153,8 +1156,16 @@ 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``.
|
||||
.. note::
|
||||
We keep this entrypoint for backward compatibility and simply dispatch the request to :meth:`do_POST_mpp`.
|
||||
"""
|
||||
self.do_POST_mpp()
|
||||
|
||||
def do_POST_mpp(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/mpp`` path.
|
||||
|
||||
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.
|
||||
@@ -1164,9 +1175,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
patroni = self.server.patroni
|
||||
if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader():
|
||||
if patroni.postgresql.mpp_handler.is_coordinator() and patroni.ha.is_leader():
|
||||
cluster = patroni.dcs.get_cluster()
|
||||
patroni.postgresql.citus_handler.handle_event(cluster, request)
|
||||
patroni.postgresql.mpp_handler.handle_event(cluster, request)
|
||||
self.write_response(200, 'OK')
|
||||
|
||||
def parse_request(self) -> bool:
|
||||
|
||||
+35
-19
@@ -1,4 +1,5 @@
|
||||
"""Facilities related to Patroni configuration."""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -142,8 +143,9 @@ 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
|
||||
|
||||
@property
|
||||
@@ -355,6 +357,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.')
|
||||
@@ -532,8 +535,8 @@ class Config(object):
|
||||
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
|
||||
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
|
||||
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
|
||||
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
|
||||
'dir', 'file_size', 'file_num', 'loggers'])
|
||||
_set_section_values('log', ['type', 'level', 'traceback_level', 'format', 'dateformat', 'static_fields',
|
||||
'max_queue_size', 'dir', 'file_size', 'file_num', 'loggers'])
|
||||
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
|
||||
|
||||
for binary in ('pg_ctl', 'initdb', 'pg_controldata', 'pg_basebackup', 'postgres', 'pg_isready', 'pg_rewind'):
|
||||
@@ -580,6 +583,12 @@ class Config(object):
|
||||
if value:
|
||||
ret[first][second] = value
|
||||
|
||||
logformat = ret.get('log', {}).get('format')
|
||||
if logformat and not re.search(r'%\(\w+\)', logformat):
|
||||
logformat = _parse_list(logformat)
|
||||
if logformat:
|
||||
ret['log']['format'] = logformat
|
||||
|
||||
def _parse_dict(value: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse an YAML dictionary *value* as a :class:`dict`.
|
||||
|
||||
@@ -595,7 +604,12 @@ class Config(object):
|
||||
logger.exception('Exception when parsing dict %s', value)
|
||||
return None
|
||||
|
||||
for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
|
||||
dict_configs = (
|
||||
('restapi', ('http_extra_headers', 'https_extra_headers')),
|
||||
('log', ('static_fields', 'loggers'))
|
||||
)
|
||||
|
||||
for first, params in dict_configs:
|
||||
for second in params:
|
||||
value = ret.get(first, {}).pop(second, None)
|
||||
if value:
|
||||
@@ -745,11 +759,14 @@ class Config(object):
|
||||
dcs = bootstrap.setdefault('dcs', {})
|
||||
dcs.setdefault('synchronous_mode', True)
|
||||
|
||||
if 'tags' in config:
|
||||
self._validate_failover_tags(config['tags'])
|
||||
updated_fields = (
|
||||
'name',
|
||||
'scope',
|
||||
'retry_timeout',
|
||||
'citus'
|
||||
)
|
||||
|
||||
# Add params required inside Postgresql class to PG config
|
||||
pg_config.update({p: config[p] for p in ('name', 'scope', 'retry_timeout', 'citus') if p in config})
|
||||
pg_config.update({p: config[p] for p in updated_fields if p in config})
|
||||
|
||||
return config
|
||||
|
||||
@@ -797,11 +814,8 @@ class Config(object):
|
||||
"""
|
||||
return deepcopy(self.__effective_configuration)
|
||||
|
||||
@staticmethod
|
||||
def _validate_failover_tags(tags_config: Dict[str, Any]) -> None:
|
||||
"""Check ``nofailover``/``failover_priority`` config, remove contradictory tag and warn user.
|
||||
|
||||
:param tags_config: dictionary representing values under the ``tags`` configuration section.
|
||||
def _validate_failover_tags(self) -> None:
|
||||
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
|
||||
|
||||
.. note::
|
||||
To preserve sanity (and backwards compatibility) the ``nofailover`` tag will still exist. A contradictory
|
||||
@@ -812,11 +826,13 @@ class Config(object):
|
||||
The behaviour is as if ``failover_priority`` were not provided (i.e ``nofailover`` is the
|
||||
bedrock source of truth)
|
||||
"""
|
||||
nofailover_tag = tags_config.get('nofailover')
|
||||
failover_priority_tag = parse_int(tags_config.get('failover_priority'))
|
||||
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)
|
||||
tags_config.pop('failover_priority')
|
||||
|
||||
@@ -99,6 +99,7 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
'listen': cls._IP + ':8008'
|
||||
},
|
||||
'log': {
|
||||
'type': PatroniLogger.DEFAULT_TYPE,
|
||||
'level': PatroniLogger.DEFAULT_LEVEL,
|
||||
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||
@@ -178,7 +179,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)
|
||||
|
||||
|
||||
+37
-25
@@ -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
|
||||
dcs.is_mpp_coordinator = lambda: True
|
||||
click.get_current_context().obj['__mpp'] = dcs.mpp
|
||||
return dcs
|
||||
except PatroniException as e:
|
||||
raise PatroniCtlException(str(e))
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1406,7 +1410,7 @@ def switchover(cluster_name: str, group: Optional[int], leader: Optional[str],
|
||||
|
||||
|
||||
def generate_topology(level: int, member: Dict[str, Any],
|
||||
topology: Dict[str, List[Dict[str, Any]]]) -> Iterator[Dict[str, Any]]:
|
||||
topology: Dict[Optional[str], List[Dict[str, Any]]]) -> Iterator[Dict[str, Any]]:
|
||||
"""Recursively yield members with their names adjusted according to their *level* in the cluster topology.
|
||||
|
||||
.. note::
|
||||
@@ -1469,7 +1473,7 @@ def topology_sort(members: List[Dict[str, Any]]) -> Iterator[Dict[str, Any]]:
|
||||
:yields: *members* sorted by level in the topology, and with a new ``name`` value according to their level
|
||||
in the topology.
|
||||
"""
|
||||
topology: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
topology: Dict[Optional[str], List[Dict[str, Any]]] = defaultdict(list)
|
||||
leader = next((m for m in members if m['role'].endswith('leader')), {'name': None})
|
||||
replicas = set(member['name'] for member in members if not member['role'].endswith('leader'))
|
||||
for member in members:
|
||||
@@ -1554,7 +1558,7 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
|
||||
|
||||
all_members = [m for c in clusters.values() for m in c['members'] if 'host' in m]
|
||||
|
||||
for c in ('Pending restart', 'Scheduled restart', 'Tags'):
|
||||
for c in ('Pending restart', 'Pending restart reason', 'Scheduled restart', 'Tags'):
|
||||
if extended or any(m.get(c.lower().replace(' ', '_')) for m in all_members):
|
||||
columns.append(c)
|
||||
|
||||
@@ -1568,11 +1572,19 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
|
||||
logging.debug(member)
|
||||
|
||||
lag = member.get('lag', '')
|
||||
|
||||
def format_diff(param: str, values: Dict[str, str], hide_long: bool):
|
||||
full_diff = param + ': ' + values['old_value'] + '->' + values['new_value']
|
||||
return full_diff if not hide_long or len(full_diff) <= 50 else param + ': [hidden - too long]'
|
||||
restart_reason = '\n'.join([format_diff(k, v, fmt in ('pretty', 'topology'))
|
||||
for k, v in member.get('pending_restart_reason', {}).items()]) or ''
|
||||
|
||||
member.update(cluster=name, member=member['name'], group=g,
|
||||
host=member.get('host', ''), tl=member.get('timeline', ''),
|
||||
role=member['role'].replace('_', ' ').title(),
|
||||
lag_in_mb=round(lag / 1024 / 1024) if isinstance(lag, int) else lag,
|
||||
pending_restart='*' if member.get('pending_restart') else '')
|
||||
pending_restart='*' if member.get('pending_restart') else '',
|
||||
pending_restart_reason=restart_reason)
|
||||
|
||||
if append_port and member['host'] and member.get('port'):
|
||||
member['host'] = ':'.join([member['host'], str(member['port'])])
|
||||
|
||||
+49
-45
@@ -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"
|
||||
@@ -784,7 +782,7 @@ class Cluster(NamedTuple('Cluster',
|
||||
('history', Optional[TimelineHistory]),
|
||||
('failsafe', Optional[Dict[str, str]]),
|
||||
('workers', Dict[int, 'Cluster'])])):
|
||||
"""Immutable object (namedtuple) which represents PostgreSQL or Citus cluster.
|
||||
"""Immutable object (namedtuple) which represents PostgreSQL or MPP cluster.
|
||||
|
||||
.. note::
|
||||
We are using an old-style attribute declaration here because otherwise it is not possible to override `__new__`
|
||||
@@ -801,8 +799,8 @@ class Cluster(NamedTuple('Cluster',
|
||||
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
|
||||
:ivar history: reference to `TimelineHistory` object.
|
||||
:ivar failsafe: failsafe topology. Node is allowed to become the leader only if its name is found in this list.
|
||||
:ivar workers: dictionary of workers of the Citus cluster, optional. Each key is an :class:`int` representing
|
||||
the group, and the corresponding value is a :class:`Cluster` instance.
|
||||
:ivar workers: dictionary of workers of the MPP cluster, optional. Each key representing the group and the
|
||||
corresponding value is a :class:`Cluster` instance.
|
||||
"""
|
||||
|
||||
def __new__(cls, *args: Any, **kwargs: Any):
|
||||
@@ -1265,11 +1263,11 @@ class AbstractDCS(abc.ABC):
|
||||
Functional methods that are critical in their timing, required to complete within ``retry_timeout`` period in order
|
||||
to prevent the DCS considered inaccessible, each perform construction of complex data objects:
|
||||
|
||||
* :meth:`~AbstractDCS._cluster_loader`:
|
||||
* :meth:`~AbstractDCS._postgresql_cluster_loader`:
|
||||
method which processes the structure of data stored in the DCS used to build the :class:`Cluster` object
|
||||
with all relevant associated data.
|
||||
* :meth:`~AbstractDCS._citus_cluster_loader`:
|
||||
Similar to above but specifically representing Citus group and workers information.
|
||||
* :meth:`~AbstractDCS._mpp_cluster_loader`:
|
||||
Similar to above but specifically representing MPP group and workers information.
|
||||
* :meth:`~AbstractDCS._load_cluster`:
|
||||
main method for calling specific ``loader`` method to build the :class:`Cluster` object representing the
|
||||
state and topology of the cluster.
|
||||
@@ -1338,15 +1336,15 @@ class AbstractDCS(abc.ABC):
|
||||
_SYNC = 'sync'
|
||||
_FAILSAFE = 'failsafe'
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
"""Prepare DCS paths, Citus group ID, initial values for state information and processing dependencies.
|
||||
def __init__(self, config: Dict[str, Any], mpp: 'AbstractMPP') -> None:
|
||||
"""Prepare DCS paths, MPP object, 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))
|
||||
@@ -1359,6 +1357,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.
|
||||
|
||||
@@ -1367,8 +1370,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)
|
||||
|
||||
@@ -1469,22 +1472,21 @@ class AbstractDCS(abc.ABC):
|
||||
return self._last_seen
|
||||
|
||||
@abc.abstractmethod
|
||||
def _cluster_loader(self, path: Any) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single Patroni or Citus cluster.
|
||||
def _postgresql_cluster_loader(self, path: Any) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _citus_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
|
||||
"""Load and build all Patroni clusters from a single Citus cluster.
|
||||
def _mpp_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all Citus groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values or a
|
||||
:class:`Cluster` object representing the coordinator with filled `Cluster.workers` attribute.
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
@@ -1499,13 +1501,14 @@ class AbstractDCS(abc.ABC):
|
||||
the :meth:`~AbstractDCS.get_cluster` method.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
:param loader: one of :meth:`~AbstractDCS._cluster_loader` or :meth:`~AbstractDCS._citus_cluster_loader`.
|
||||
:param loader: one of :meth:`~AbstractDCS._postgresql_cluster_loader` or
|
||||
:meth:`~AbstractDCS._mpp_cluster_loader`.
|
||||
|
||||
:raise: :exc:`~DCSError` in case of communication problems with DCS. If the current node was running as a
|
||||
primary and exception raised, instance would be demoted.
|
||||
"""
|
||||
|
||||
def __get_patroni_cluster(self, path: Optional[str] = None) -> Cluster:
|
||||
def __get_postgresql_cluster(self, path: Optional[str] = None) -> Cluster:
|
||||
"""Low level method to load a :class:`Cluster` object from DCS.
|
||||
|
||||
:param path: optional client path in DCS backend to load from.
|
||||
@@ -1514,42 +1517,43 @@ class AbstractDCS(abc.ABC):
|
||||
"""
|
||||
if path is None:
|
||||
path = self.client_path('')
|
||||
cluster = self._load_cluster(path, self._cluster_loader)
|
||||
cluster = self._load_cluster(path, self._postgresql_cluster_loader)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(cluster, Cluster)
|
||||
return cluster
|
||||
|
||||
def is_citus_coordinator(self) -> bool:
|
||||
""":class:`Cluster` instance has a Citus Coordinator group ID.
|
||||
def is_mpp_coordinator(self) -> bool:
|
||||
""":class:`Cluster` instance has a 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.
|
||||
def get_mpp_coordinator(self) -> Optional[Cluster]:
|
||||
"""Load the PostgreSQL cluster for the MPP Coordinator.
|
||||
|
||||
.. note::
|
||||
This method is only executed on the worker nodes (``group!=0``) to find the coordinator.
|
||||
.. note::
|
||||
This method is only executed on the worker nodes 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_postgresql_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)
|
||||
logger.error('Failed to load %s coordinator cluster from %s: %r',
|
||||
self._mpp.type, self.__class__.__name__, e)
|
||||
return None
|
||||
|
||||
def _get_citus_cluster(self) -> Cluster:
|
||||
"""Load Citus cluster from DCS.
|
||||
def _get_mpp_cluster(self) -> Cluster:
|
||||
"""Load MPP cluster from DCS.
|
||||
|
||||
:returns: A Citus :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
|
||||
:returns: A MPP :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
|
||||
dict.
|
||||
"""
|
||||
groups = self._load_cluster(self._base_path + '/', self._citus_cluster_loader)
|
||||
groups = self._load_cluster(self._base_path + '/', self._mpp_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
|
||||
|
||||
@@ -1560,12 +1564,12 @@ class AbstractDCS(abc.ABC):
|
||||
Stores copy of time, status and failsafe values for comparison in DCS update decisions.
|
||||
Caching is required to avoid overhead placed upon the REST API.
|
||||
|
||||
Returns either a Citus or Patroni implementation of :class:`Cluster` depending on availability.
|
||||
Returns either a PostgreSQL or MPP implementation of :class:`Cluster` depending on availability.
|
||||
|
||||
:returns:
|
||||
"""
|
||||
try:
|
||||
cluster = self._get_citus_cluster() if self.is_citus_coordinator() else self.__get_patroni_cluster()
|
||||
cluster = self._get_mpp_cluster() if self.is_mpp_coordinator() else self.__get_postgresql_cluster()
|
||||
except Exception:
|
||||
self.reset_cluster()
|
||||
raise
|
||||
|
||||
+73
-8
@@ -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
|
||||
@@ -41,6 +42,57 @@ class InvalidSession(ConsulException):
|
||||
"""invalid session"""
|
||||
|
||||
|
||||
class ConsulAgentService(base.Consul.Agent.Service):
|
||||
"""
|
||||
Consul.Agent.Session with support of ``tagged_addresses``.
|
||||
|
||||
We do it in the Patroni code because ``python-consul`` and
|
||||
``python-consul2`` modules don't receive any updates for at least 3 years.
|
||||
"""
|
||||
|
||||
def register(self, name: str, service_id: Optional[str] = None, address: Optional[str] = None,
|
||||
port: Optional[int] = None, tags: Optional[List[str]] = None, check: Optional[Dict[str, str]] = None,
|
||||
token: Optional[str] = None, enable_tag_override: bool = False,
|
||||
tagged_addresses: Optional[Dict[str, Dict[str, Union[str, int]]]] = None, **kwargs: Any) -> bool:
|
||||
"""Add a new service to the local agent.
|
||||
|
||||
:param name: name of the service.
|
||||
:param service_id: service id, optional, if not provided *name* is used.
|
||||
:param address: will default to the address of the agent if not provided.
|
||||
:param port: port on which the service is available.
|
||||
:param tagged_addresses: additional addresses for a node or service.
|
||||
:tags: a list of string values that add service-level labels.
|
||||
:enable_tag_override: optional ``bool`` that enable you to modify a service tags from servers
|
||||
(consul agent role server). Default is set to ``False``.
|
||||
:check: an optional health check for this service.
|
||||
:token: an optional ACL token to apply to this request.
|
||||
|
||||
:returns: ``True`` if the service was successfully registered/updated, otherwise ``False``.
|
||||
"""
|
||||
payload: Dict[str, Any] = {'name': name}
|
||||
|
||||
if enable_tag_override:
|
||||
payload['enabletagoverride'] = enable_tag_override
|
||||
if service_id:
|
||||
payload['id'] = service_id
|
||||
if address:
|
||||
payload['address'] = address
|
||||
if port:
|
||||
payload['port'] = port
|
||||
if tagged_addresses:
|
||||
payload['tagged_addresses'] = tagged_addresses
|
||||
if tags:
|
||||
payload['tags'] = tags
|
||||
if check:
|
||||
payload['check'] = check
|
||||
|
||||
token = token or self.agent.token
|
||||
params = {'token': token} if token else {}
|
||||
|
||||
return self.agent.http.put(base.CB.bool(), '/v1/agent/service/register',
|
||||
params=params, data=json.dumps(payload))
|
||||
|
||||
|
||||
class Response(NamedTuple):
|
||||
code: int
|
||||
headers: Union[Mapping[str, str], Mapping[bytes, bytes], None]
|
||||
@@ -232,8 +284,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
|
||||
@@ -268,6 +320,7 @@ class Consul(AbstractDCS):
|
||||
kwargs['verify'] = verify
|
||||
|
||||
self._client = ConsulClient(**kwargs)
|
||||
self._agent_service = ConsulAgentService(self._client)
|
||||
self.set_retry_timeout(config['retry_timeout'])
|
||||
self.set_ttl(config.get('ttl') or 30)
|
||||
self._last_session_refresh = 0
|
||||
@@ -419,7 +472,13 @@ class Consul(AbstractDCS):
|
||||
def _consistency(self) -> str:
|
||||
return 'consistent' if self._ctl else self._client.consistency
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||
if results is None:
|
||||
return Cluster.empty()
|
||||
@@ -430,12 +489,18 @@ class Consul(AbstractDCS):
|
||||
|
||||
return self._cluster_from_nodes(nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||
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()}
|
||||
@@ -490,14 +555,14 @@ class Consul(AbstractDCS):
|
||||
@catch_consul_errors
|
||||
def register_service(self, service_name: str, **kwargs: Any) -> bool:
|
||||
logger.info('Register service %s, params %s', service_name, kwargs)
|
||||
return self._client.agent.service.register(service_name, **kwargs)
|
||||
return self._agent_service.register(service_name, **kwargs)
|
||||
|
||||
@catch_consul_errors
|
||||
def deregister_service(self, service_id: str) -> bool:
|
||||
logger.info('Deregister service %s', service_id)
|
||||
# service_id can contain special characters, but is used as part of uri in deregister request
|
||||
service_id = quote(service_id)
|
||||
return self._client.agent.service.deregister(service_id)
|
||||
return self._agent_service.deregister(service_id)
|
||||
|
||||
def _update_service(self, data: Dict[str, Any]) -> Optional[bool]:
|
||||
service_name = self._service_name
|
||||
|
||||
+21
-8
@@ -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
|
||||
@@ -709,7 +710,13 @@ class Etcd(AbstractEtcd):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
try:
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
@@ -717,7 +724,13 @@ class Etcd(AbstractEtcd):
|
||||
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
|
||||
return self._cluster_from_nodes(result.etcd_index, nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
try:
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
except etcd.EtcdKeyNotFound:
|
||||
@@ -726,7 +739,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()}
|
||||
|
||||
|
||||
+25
-7
@@ -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
|
||||
@@ -731,7 +733,11 @@ class Etcd3(AbstractEtcd):
|
||||
|
||||
@property
|
||||
def cluster_prefix(self) -> str:
|
||||
return self._base_path + '/' if self.is_citus_coordinator() else self.client_path('')
|
||||
"""Construct the cluster prefix for the cluster.
|
||||
|
||||
:returns: path in the DCS under which we store information about this Patroni cluster.
|
||||
"""
|
||||
return self._base_path + '/' if self.is_mpp_coordinator() else self.client_path('')
|
||||
|
||||
@staticmethod
|
||||
def member(node: Dict[str, str]) -> Member:
|
||||
@@ -785,18 +791,30 @@ class Etcd3(AbstractEtcd):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
nodes = {node['key'][len(path):]: node
|
||||
for node in self._client.get_cluster(path)
|
||||
if node['key'].startswith(path)}
|
||||
return self._cluster_from_nodes(nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
|
||||
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]]]
|
||||
|
||||
+37
-20
@@ -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
|
||||
@@ -746,9 +746,7 @@ class ObjectCache(Thread):
|
||||
|
||||
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 +757,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._mpp.k8s_group_label] = str(self._mpp.group)
|
||||
|
||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||
retry_exceptions=KubernetesRetriableException)
|
||||
@@ -936,20 +934,32 @@ class Kubernetes(AbstractDCS):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: Dict[str, Any]) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: Dict[str, Any]) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values())
|
||||
|
||||
def _citus_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
clusters: Dict[str, Dict[str, Dict[str, K8sObject]]] = defaultdict(lambda: defaultdict(dict))
|
||||
|
||||
for name, pod in path['pods'].items():
|
||||
group = pod.metadata.labels.get(self._CITUS_LABEL)
|
||||
if group and citus_group_re.match(group):
|
||||
group = pod.metadata.labels.get(self._mpp.k8s_group_label)
|
||||
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):
|
||||
group = kind.metadata.labels.get(self._mpp.k8s_group_label)
|
||||
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()}
|
||||
@@ -965,9 +975,9 @@ class Kubernetes(AbstractDCS):
|
||||
with self._condition:
|
||||
self._wait_caches(stop_time)
|
||||
pods = {name: pod for name, pod in self._pods.copy().items()
|
||||
if not group or pod.metadata.labels.get(self._CITUS_LABEL) == group}
|
||||
if not group or pod.metadata.labels.get(self._mpp.k8s_group_label) == group}
|
||||
nodes = {name: kind for name, kind in self._kinds.copy().items()
|
||||
if not group or kind.metadata.labels.get(self._CITUS_LABEL) == group}
|
||||
if not group or kind.metadata.labels.get(self._mpp.k8s_group_label) == group}
|
||||
return loader({'group': group, 'pods': pods, 'nodes': nodes})
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
@@ -976,17 +986,24 @@ 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]:
|
||||
def get_mpp_coordinator(self) -> Optional[Cluster]:
|
||||
"""Load the PostgreSQL cluster for the MPP Coordinator.
|
||||
|
||||
.. note::
|
||||
This method is only executed on the worker nodes to find the coordinator.
|
||||
|
||||
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
|
||||
"""
|
||||
try:
|
||||
ret = self.__load_cluster(str(CITUS_COORDINATOR_GROUP_ID), self._cluster_loader)
|
||||
ret = self.__load_cluster(str(self._mpp.coordinator_group_id), self._postgresql_cluster_loader)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(ret, Cluster)
|
||||
return ret
|
||||
except Exception as e:
|
||||
logger.error('Failed to load Citus coordinator cluster from Kubernetes: %r', e)
|
||||
logger.error('Failed to load %s coordinator cluster from Kubernetes: %r', self._mpp.type, e)
|
||||
|
||||
@staticmethod
|
||||
def compare_ports(p1: K8sObject, p2: K8sObject) -> bool:
|
||||
|
||||
+19
-7
@@ -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()
|
||||
@@ -375,19 +375,31 @@ class Raft(AbstractDCS):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
response = self._sync_obj.get(path, recursive=True)
|
||||
if not response:
|
||||
return Cluster.empty()
|
||||
nodes = {key[len(path):]: value for key, value in response.items()}
|
||||
return self._cluster_from_nodes(nodes)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
clusters: Dict[int, Dict[str, Any]] = defaultdict(dict)
|
||||
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):
|
||||
@@ -214,7 +214,13 @@ class ZooKeeper(AbstractDCS):
|
||||
members.append(self.member(member, *data))
|
||||
return members
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
||||
|
||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
||||
|
||||
:returns: :class:`Cluster` instance.
|
||||
"""
|
||||
nodes = set(self.get_children(path))
|
||||
|
||||
# get initialize flag
|
||||
@@ -258,11 +264,17 @@ class ZooKeeper(AbstractDCS):
|
||||
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
||||
|
||||
:param path: the path in DCS where to load Cluster(s) from.
|
||||
|
||||
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
|
||||
"""
|
||||
ret: Dict[int, Cluster] = {}
|
||||
for node in self.get_children(path):
|
||||
if citus_group_re.match(node):
|
||||
ret[int(node)] = self._cluster_loader(path + node + '/')
|
||||
if self._mpp.group_re.match(node):
|
||||
ret[int(node)] = self._postgresql_cluster_loader(path + node + '/')
|
||||
return ret
|
||||
|
||||
def _load_cluster(
|
||||
|
||||
+25
-18
@@ -175,7 +175,7 @@ class Ha(object):
|
||||
# Count of concurrent sync disabling requests. Value above zero means that we don't want to be synchronous
|
||||
# standby. Changes protected by _member_state_lock.
|
||||
self._disable_sync = 0
|
||||
# Remember the last known member role and state written to the DCS in order to notify Citus coordinator
|
||||
# Remember the last known member role and state written to the DCS in order to notify MPP coordinator
|
||||
self._last_state = None
|
||||
|
||||
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
|
||||
@@ -326,20 +326,26 @@ class Ha(object):
|
||||
tags['nosync'] = True
|
||||
return tags
|
||||
|
||||
def notify_citus_coordinator(self, event: str) -> None:
|
||||
if self.state_handler.citus_handler.is_worker():
|
||||
coordinator = self.dcs.get_citus_coordinator()
|
||||
def notify_mpp_coordinator(self, event: str) -> None:
|
||||
"""Send an event to the MPP coordinator.
|
||||
|
||||
:param event: the type of event for coordinator to parse.
|
||||
"""
|
||||
mpp_handler = self.state_handler.mpp_handler
|
||||
if mpp_handler.is_worker():
|
||||
coordinator = self.dcs.get_mpp_coordinator()
|
||||
if coordinator and coordinator.leader and coordinator.leader.conn_url:
|
||||
try:
|
||||
data = {'type': event,
|
||||
'group': self.state_handler.citus_handler.group(),
|
||||
'group': mpp_handler.group,
|
||||
'leader': self.state_handler.name,
|
||||
'timeout': self.dcs.ttl,
|
||||
'cooldown': self.patroni.config['retry_timeout']}
|
||||
timeout = self.dcs.ttl if event == 'before_demote' else 2
|
||||
self.patroni.request(coordinator.leader.member, 'post', 'citus', data, timeout=timeout, retries=0)
|
||||
endpoint = 'citus' if mpp_handler.type == 'Citus' else 'mpp'
|
||||
self.patroni.request(coordinator.leader.member, 'post', endpoint, data, timeout=timeout, retries=0)
|
||||
except Exception as e:
|
||||
logger.warning('Request to Citus coordinator leader %s %s failed: %r',
|
||||
logger.warning('Request to %s coordinator leader %s %s failed: %r', mpp_handler.type,
|
||||
coordinator.leader.name, coordinator.leader.member.api_url, e)
|
||||
|
||||
def touch_member(self) -> bool:
|
||||
@@ -361,8 +367,9 @@ class Ha(object):
|
||||
tags = self.get_effective_tags()
|
||||
if tags:
|
||||
data['tags'] = tags
|
||||
if self.state_handler.pending_restart:
|
||||
if self.state_handler.pending_restart_reason:
|
||||
data['pending_restart'] = True
|
||||
data['pending_restart_reason'] = dict(self.state_handler.pending_restart_reason)
|
||||
if self._async_executor.scheduled_action in (None, 'promote') \
|
||||
and data['state'] in ['running', 'restarting', 'starting']:
|
||||
try:
|
||||
@@ -402,7 +409,7 @@ class Ha(object):
|
||||
if ret:
|
||||
new_state = (data['state'], {'master': 'primary'}.get(data['role'], data['role']))
|
||||
if self._last_state != new_state and new_state == ('running', 'primary'):
|
||||
self.notify_citus_coordinator('after_promote')
|
||||
self.notify_mpp_coordinator('after_promote')
|
||||
self._last_state = new_state
|
||||
return ret
|
||||
|
||||
@@ -847,7 +854,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.mpp_handler.sync_meta_data(self.cluster)
|
||||
return message
|
||||
elif self.state_handler.role in ('master', 'promoted', 'primary'):
|
||||
self.process_sync_replication()
|
||||
@@ -867,7 +874,7 @@ class Ha(object):
|
||||
self._failsafe.set_is_active(0)
|
||||
|
||||
def before_promote():
|
||||
self.notify_citus_coordinator('before_promote')
|
||||
self.notify_mpp_coordinator('before_promote')
|
||||
|
||||
with self._async_response:
|
||||
self._async_response.reset()
|
||||
@@ -1238,10 +1245,10 @@ class Ha(object):
|
||||
status['released'] = True
|
||||
|
||||
def before_shutdown() -> None:
|
||||
if self.state_handler.citus_handler.is_coordinator():
|
||||
self.state_handler.citus_handler.on_demote()
|
||||
if self.state_handler.mpp_handler.is_coordinator():
|
||||
self.state_handler.mpp_handler.on_demote()
|
||||
else:
|
||||
self.notify_citus_coordinator('before_demote')
|
||||
self.notify_mpp_coordinator('before_demote')
|
||||
|
||||
self.state_handler.stop(str(mode_control['stop']), checkpoint=bool(mode_control['checkpoint']),
|
||||
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None,
|
||||
@@ -1485,7 +1492,7 @@ class Ha(object):
|
||||
if postgres_version and postgres_version_to_int(postgres_version) <= int(self.state_handler.server_version):
|
||||
reason_to_cancel = "postgres version mismatch"
|
||||
|
||||
if pending_restart and not self.state_handler.pending_restart:
|
||||
if pending_restart and not self.state_handler.pending_restart_reason:
|
||||
reason_to_cancel = "pending restart flag is not set"
|
||||
|
||||
if not reason_to_cancel:
|
||||
@@ -1543,10 +1550,10 @@ class Ha(object):
|
||||
self.set_start_timeout(timeout)
|
||||
|
||||
def before_shutdown() -> None:
|
||||
self.notify_citus_coordinator('before_demote')
|
||||
self.notify_mpp_coordinator('before_demote')
|
||||
|
||||
def after_start() -> None:
|
||||
self.notify_citus_coordinator('after_promote')
|
||||
self.notify_mpp_coordinator('after_promote')
|
||||
|
||||
# For non async cases we want to wait for restart to complete or timeout before returning.
|
||||
do_restart = functools.partial(self.state_handler.restart, timeout, self._async_executor.critical_task,
|
||||
@@ -1999,7 +2006,7 @@ class Ha(object):
|
||||
self.dcs.write_leader_optime(prev_location)
|
||||
|
||||
def _before_shutdown() -> None:
|
||||
self.notify_citus_coordinator('before_demote')
|
||||
self.notify_mpp_coordinator('before_demote')
|
||||
|
||||
on_shutdown = _on_shutdown if self.is_leader() else None
|
||||
before_shutdown = _before_shutdown if self.is_leader() else None
|
||||
|
||||
+166
-20
@@ -9,12 +9,15 @@ import sys
|
||||
|
||||
from copy import deepcopy
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from patroni.utils import deep_compare
|
||||
from queue import Queue, Full
|
||||
from threading import Lock, Thread
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
|
||||
|
||||
from .utils import deep_compare
|
||||
|
||||
type_logformat = Union[List[Union[str, Dict[str, Any], Any]], str, Any]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -157,6 +160,7 @@ class PatroniLogger(Thread):
|
||||
.. seealso::
|
||||
:class:`QueueHandler`: object used for enqueueing messages in-memory.
|
||||
|
||||
:cvar DEFAULT_TYPE: default type of log format (``plain``).
|
||||
:cvar DEFAULT_LEVEL: default logging level (``INFO``).
|
||||
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
|
||||
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
|
||||
@@ -169,6 +173,7 @@ class PatroniLogger(Thread):
|
||||
:ivar log_handler_lock: lock used to modify ``log_handler``.
|
||||
"""
|
||||
|
||||
DEFAULT_TYPE = 'plain'
|
||||
DEFAULT_LEVEL = 'INFO'
|
||||
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
|
||||
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
|
||||
@@ -237,6 +242,151 @@ class PatroniLogger(Thread):
|
||||
logger = self._root_logger.manager.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
def _is_config_changed(self, config: Dict[str, Any]) -> bool:
|
||||
"""Checks if the given config is different from the current one.
|
||||
|
||||
:param config: ``log`` section from Patroni configuration.
|
||||
|
||||
:returns: ``True`` if the config is changed, ``False`` otherwise.
|
||||
"""
|
||||
old_config = self._config or {}
|
||||
|
||||
oldlogtype = old_config.get('type', PatroniLogger.DEFAULT_TYPE)
|
||||
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
|
||||
|
||||
oldlogformat: type_logformat = old_config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
|
||||
olddateformat = old_config.get('dateformat') or None
|
||||
dateformat = config.get('dateformat') or None # Convert empty string to `None`
|
||||
|
||||
old_static_fields = old_config.get('static_fields', {})
|
||||
static_fields = config.get('static_fields', {})
|
||||
|
||||
old_log_config = {
|
||||
'type': oldlogtype,
|
||||
'format': oldlogformat,
|
||||
'dateformat': olddateformat,
|
||||
'static_fields': old_static_fields
|
||||
}
|
||||
|
||||
log_config = {
|
||||
'type': logtype,
|
||||
'format': logformat,
|
||||
'dateformat': dateformat,
|
||||
'static_fields': static_fields
|
||||
}
|
||||
|
||||
return not deep_compare(old_log_config, log_config)
|
||||
|
||||
def _get_plain_formatter(self, logformat: type_logformat, dateformat: Optional[str]) -> logging.Formatter:
|
||||
"""Returns a logging formatter with the specified format and date format.
|
||||
|
||||
.. note::
|
||||
If the log format isn't a string, prints a warning message and uses the default log format instead.
|
||||
|
||||
:param logformat: The format of the log messages.
|
||||
:param dateformat: The format of the timestamp in the log messages.
|
||||
|
||||
:returns: A logging formatter object that can be used to format log records.
|
||||
"""
|
||||
|
||||
if not isinstance(logformat, str):
|
||||
_LOGGER.warning('Expected log format to be a string when log type is plain, but got "%s"', type(logformat))
|
||||
logformat = PatroniLogger.DEFAULT_FORMAT
|
||||
|
||||
return logging.Formatter(logformat, dateformat)
|
||||
|
||||
def _get_json_formatter(self, logformat: type_logformat, dateformat: Optional[str],
|
||||
static_fields: Dict[str, Any]) -> logging.Formatter:
|
||||
"""Returns a logging formatter that outputs JSON formatted messages.
|
||||
|
||||
.. note::
|
||||
If :mod:`pythonjsonlogger` library is not installed, prints an error message and returns
|
||||
a plain log formatter instead.
|
||||
|
||||
:param logformat: Specifies the log fields and their key names in the JSON log message.
|
||||
:param dateformat: The format of the timestamp in the log messages.
|
||||
:param static_fields: A dictionary of static fields that are added to every log message.
|
||||
|
||||
:returns: A logging formatter object that can be used to format log records as JSON strings.
|
||||
"""
|
||||
|
||||
if isinstance(logformat, str):
|
||||
jsonformat = logformat
|
||||
rename_fields = {}
|
||||
elif isinstance(logformat, list):
|
||||
log_fields: List[str] = []
|
||||
rename_fields: Dict[str, str] = {}
|
||||
|
||||
for field in logformat:
|
||||
if isinstance(field, str):
|
||||
log_fields.append(field)
|
||||
elif isinstance(field, dict):
|
||||
for original_field, renamed_field in field.items():
|
||||
if isinstance(renamed_field, str):
|
||||
log_fields.append(original_field)
|
||||
rename_fields[original_field] = renamed_field
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
'Expected renamed log field to be a string, but got "%s"',
|
||||
type(renamed_field)
|
||||
)
|
||||
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
'Expected each item of log format to be a string or dictionary, but got "%s"',
|
||||
type(field)
|
||||
)
|
||||
|
||||
if len(log_fields) > 0:
|
||||
jsonformat = ' '.join([f'%({field})s' for field in log_fields])
|
||||
else:
|
||||
jsonformat = PatroniLogger.DEFAULT_FORMAT
|
||||
else:
|
||||
jsonformat = PatroniLogger.DEFAULT_FORMAT
|
||||
rename_fields = {}
|
||||
_LOGGER.warning('Expected log format to be a string or a list, but got "%s"', type(logformat))
|
||||
|
||||
try:
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
return jsonlogger.JsonFormatter(
|
||||
jsonformat,
|
||||
dateformat,
|
||||
rename_fields=rename_fields,
|
||||
static_fields=static_fields
|
||||
)
|
||||
except ImportError as e:
|
||||
_LOGGER.error('Failed to import "python-json-logger" library: %r. Falling back to the plain logger', e)
|
||||
except Exception as e:
|
||||
_LOGGER.error('Failed to initialize JsonFormatter: %r. Falling back to the plain logger', e)
|
||||
|
||||
return self._get_plain_formatter(jsonformat, dateformat)
|
||||
|
||||
def _get_formatter(self, config: Dict[str, Any]) -> logging.Formatter:
|
||||
"""Returns a logging formatter based on the type of logger in the given configuration.
|
||||
|
||||
:param config: ``log`` section from Patroni configuration.
|
||||
|
||||
:returns: A :class:`logging.Formatter` object that can be used to format log records.
|
||||
"""
|
||||
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
|
||||
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
dateformat = config.get('dateformat') or None # Convert empty string to `None`
|
||||
static_fields = config.get('static_fields', {})
|
||||
|
||||
if dateformat is not None and not isinstance(dateformat, str):
|
||||
_LOGGER.warning('Expected log dateformat to be a string, but got "%s"', type(dateformat))
|
||||
dateformat = None
|
||||
|
||||
if logtype == 'json':
|
||||
formatter = self._get_json_formatter(logformat, dateformat, static_fields)
|
||||
else:
|
||||
formatter = self._get_plain_formatter(logformat, dateformat)
|
||||
|
||||
return formatter
|
||||
|
||||
def reload_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Apply log related configuration.
|
||||
|
||||
@@ -257,34 +407,30 @@ class PatroniLogger(Thread):
|
||||
# show stack traces as ``ERROR`` log messages
|
||||
logging.Logger.exception = error_exception
|
||||
|
||||
new_handler = None
|
||||
handler = self.log_handler
|
||||
|
||||
if 'dir' in config:
|
||||
if not isinstance(self.log_handler, RotatingFileHandler):
|
||||
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
|
||||
handler = new_handler or self.log_handler
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(handler, RotatingFileHandler)
|
||||
if not isinstance(handler, RotatingFileHandler):
|
||||
handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
|
||||
|
||||
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
|
||||
handler.backupCount = int(config.get('file_num', 4))
|
||||
else:
|
||||
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
|
||||
new_handler = logging.StreamHandler()
|
||||
handler = new_handler or self.log_handler
|
||||
# we can't use `if not isinstance(handler, logging.StreamHandler)` below,
|
||||
# because RotatingFileHandler is a child of StreamHandler!!!
|
||||
elif handler is None or isinstance(handler, RotatingFileHandler):
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
oldlogformat = (self._config or {}).get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||
is_new_handler = handler != self.log_handler
|
||||
|
||||
olddateformat = (self._config or {}).get('dateformat') or None
|
||||
dateformat = config.get('dateformat') or None # Convert empty string to `None`
|
||||
if (self._is_config_changed(config) or is_new_handler) and handler:
|
||||
formatter = self._get_formatter(config)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
if (oldlogformat != logformat or olddateformat != dateformat or new_handler) and handler:
|
||||
handler.setFormatter(logging.Formatter(logformat, dateformat))
|
||||
|
||||
if new_handler:
|
||||
if is_new_handler:
|
||||
with self.log_handler_lock:
|
||||
if self.log_handler:
|
||||
self._old_handlers.append(self.log_handler)
|
||||
self.log_handler = new_handler
|
||||
self.log_handler = handler
|
||||
|
||||
self._config = config.copy()
|
||||
self.update_loggers(config.get('loggers') or {})
|
||||
|
||||
@@ -19,14 +19,14 @@ 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
|
||||
from .. import global_config, psycopg
|
||||
from ..async_executor import CriticalTask
|
||||
from ..collections import CaseInsensitiveSet
|
||||
from ..collections import CaseInsensitiveSet, CaseInsensitiveDict
|
||||
from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION
|
||||
from ..exceptions import PostgresConnectionException
|
||||
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
|
||||
@@ -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']
|
||||
@@ -77,10 +77,10 @@ class Postgresql(object):
|
||||
self._state_lock = Lock()
|
||||
self.set_state('stopped')
|
||||
|
||||
self._pending_restart = False
|
||||
self._pending_restart_reason = CaseInsensitiveDict()
|
||||
self.connection_pool = ConnectionPool()
|
||||
self._connection = self.connection_pool.get('heartbeat')
|
||||
self.citus_handler = CitusHandler(self, config.get('citus'))
|
||||
self.mpp_handler = mpp.get_handler_impl(self)
|
||||
self.config = ConfigHandler(self, config)
|
||||
self.config.check_directories()
|
||||
|
||||
@@ -321,11 +321,22 @@ class Postgresql(object):
|
||||
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout'] / 2.0
|
||||
|
||||
@property
|
||||
def pending_restart(self) -> bool:
|
||||
return self._pending_restart
|
||||
def pending_restart_reason(self) -> CaseInsensitiveDict:
|
||||
"""Get :attr:`_pending_restart_reason` value.
|
||||
|
||||
def set_pending_restart(self, value: bool) -> None:
|
||||
self._pending_restart = value
|
||||
:attr:`_pending_restart_reason` is a :class:`CaseInsensitiveDict` object of the PG parameters that are
|
||||
causing pending restart state. Every key is a parameter name, value - a dictionary containing the old
|
||||
and the new value (see :func:`~patroni.postgresql.config.get_param_diff`).
|
||||
"""
|
||||
return self._pending_restart_reason
|
||||
|
||||
def set_pending_restart_reason(self, diff_dict: CaseInsensitiveDict) -> None:
|
||||
"""Set new or update current :attr:`_pending_restart_reason`.
|
||||
|
||||
:param diff_dict: :class:``CaseInsensitiveDict`` object with the parameters that are causing pending restart
|
||||
state with the diff of their values. Used to reset/update the :attr:`_pending_restart_reason`.
|
||||
"""
|
||||
self._pending_restart_reason = diff_dict
|
||||
|
||||
@property
|
||||
def sysid(self) -> str:
|
||||
@@ -727,7 +738,7 @@ class Postgresql(object):
|
||||
self.set_role(role or self.get_postgres_role_from_data_directory())
|
||||
|
||||
self.set_state('starting')
|
||||
self._pending_restart = False
|
||||
self.set_pending_restart_reason(CaseInsensitiveDict())
|
||||
|
||||
try:
|
||||
if not self.ensure_major_version_is_known():
|
||||
@@ -1197,7 +1208,7 @@ class Postgresql(object):
|
||||
before_promote()
|
||||
|
||||
self.slots_handler.on_promote()
|
||||
self.citus_handler.schedule_cache_rebuild()
|
||||
self.mpp_handler.schedule_cache_rebuild()
|
||||
|
||||
ret = self.pg_ctl('promote', '-W')
|
||||
if ret:
|
||||
@@ -1344,7 +1355,7 @@ class Postgresql(object):
|
||||
"""
|
||||
self.ensure_major_version_is_known()
|
||||
self.slots_handler.schedule()
|
||||
self.citus_handler.schedule_cache_rebuild()
|
||||
self.mpp_handler.schedule_cache_rebuild()
|
||||
self._sysid = ''
|
||||
|
||||
def _get_gucs(self) -> CaseInsensitiveSet:
|
||||
|
||||
@@ -100,10 +100,11 @@ class Bootstrap(object):
|
||||
user_options.append('--{0}'.format(opt))
|
||||
elif isinstance(opt, dict):
|
||||
keys = list(opt.keys())
|
||||
if len(keys) != 1 or not isinstance(opt[keys[0]], str) or not option_is_allowed(keys[0]):
|
||||
if len(keys) == 1 and isinstance(opt[keys[0]], str) and option_is_allowed(keys[0]):
|
||||
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
|
||||
else:
|
||||
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
|
||||
' and value should be a string'.format(tool, opt[keys[0]]))
|
||||
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
|
||||
else:
|
||||
error_handler('Error when parsing {0} option {1}: value should be string value'
|
||||
' or a single key-value pair'.format(tool, opt))
|
||||
@@ -463,15 +464,15 @@ END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
|
||||
postgresql.restart()
|
||||
else:
|
||||
postgresql.config.replace_pg_hba()
|
||||
if postgresql.pending_restart:
|
||||
if postgresql.pending_restart_reason:
|
||||
postgresql.restart()
|
||||
else:
|
||||
postgresql.reload()
|
||||
time.sleep(1) # give a time to postgres to "reload" configuration files
|
||||
postgresql.connection().close() # close connection to reconnect with a new password
|
||||
else: # initdb
|
||||
# We may want create database and extension for citus
|
||||
self._postgresql.citus_handler.bootstrap()
|
||||
# We may want create database and extension for some MPP clusters
|
||||
self._postgresql.mpp_handler.bootstrap()
|
||||
except Exception:
|
||||
logger.exception('post_bootstrap')
|
||||
task.complete(False)
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
from contextlib import contextmanager
|
||||
from urllib.parse import urlparse, parse_qsl, unquote
|
||||
from types import TracebackType
|
||||
from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||
|
||||
from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
|
||||
from .. import global_config
|
||||
@@ -17,7 +17,8 @@ from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
|
||||
from ..exceptions import PatroniFatalException, PostgresConnectionException
|
||||
from ..file_perm import pg_perm
|
||||
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
|
||||
from ..utils import (compare_values, maybe_convert_from_base_unit, parse_bool, parse_int,
|
||||
split_host_port, uri, validate_directory, is_subpath)
|
||||
from ..validator import IntValidator, EnumValidator
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -270,6 +271,29 @@ def _bool_is_true_validator(value: Any) -> bool:
|
||||
return parse_bool(value) is True
|
||||
|
||||
|
||||
def get_param_diff(old_value: Any, new_value: Any,
|
||||
vartype: Optional[str] = None, unit: Optional[str] = None) -> Dict[str, str]:
|
||||
"""Get a dictionary representing a single PG parameter's value diff.
|
||||
|
||||
:param old_value: current :class:`str` parameter value.
|
||||
:param new_value: :class:`str` value of the paramater after a restart.
|
||||
:param vartype: the target type to parse old/new_value. See ``vartype`` argument of
|
||||
:func:`~patroni.utils.maybe_convert_from_base_unit`.
|
||||
:param unit: unit of *old/new_value*. See ``base_unit`` argument of
|
||||
:func:`~patroni.utils.maybe_convert_from_base_unit`.
|
||||
|
||||
:returns: a :class:`dict` object that contains two keys: ``old_value`` and ``new_value``
|
||||
with their values casted to :class:`str` and converted from base units (if possible).
|
||||
"""
|
||||
str_value: Callable[[Any], str] = lambda x: '' if x is None else str(x)
|
||||
return {
|
||||
'old_value': (maybe_convert_from_base_unit(str_value(old_value), vartype, unit)
|
||||
if vartype else str_value(old_value)),
|
||||
'new_value': (maybe_convert_from_base_unit(str_value(new_value), vartype, unit)
|
||||
if vartype else str_value(new_value))
|
||||
}
|
||||
|
||||
|
||||
class ConfigHandler(object):
|
||||
|
||||
# List of parameters which must be always passed to postmaster as command line options
|
||||
@@ -337,12 +361,24 @@ class ConfigHandler(object):
|
||||
|
||||
def load_current_server_parameters(self) -> None:
|
||||
"""Read GUC's values from ``pg_settings`` when Patroni is joining the the postgres that is already running."""
|
||||
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator] \
|
||||
+ [name.lower() for name in self._RECOVERY_PARAMETERS]
|
||||
self._server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
|
||||
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator]
|
||||
keep_values = {k: self._server_parameters[k] for k in exclude}
|
||||
server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
|
||||
"SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings"
|
||||
" WHERE (source IN ('command line', 'environment variable') OR sourcefile = %s)"
|
||||
" AND pg_catalog.lower(name) != ALL(%s)", self._postgresql_conf, exclude)})
|
||||
recovery_params = CaseInsensitiveDict({k: server_parameters.pop(k) for k in self._RECOVERY_PARAMETERS
|
||||
if k in server_parameters})
|
||||
# We also want to load current settings of recovery parameters, including primary_conninfo
|
||||
# and primary_slot_name, otherwise patronictl restart will update postgresql.conf
|
||||
# and remove them, what in the worst case will cause another restart.
|
||||
# We are doing it only for PostgresSQL v12 onwards, because older version still have recovery.conf
|
||||
if not self._postgresql.is_primary() and self._postgresql.major_version >= 120000:
|
||||
# primary_conninfo is expected to be a dict, therefore we need to parse it
|
||||
recovery_params['primary_conninfo'] = parse_dsn(recovery_params.pop('primary_conninfo', '')) or {}
|
||||
self._recovery_params = recovery_params
|
||||
|
||||
self._server_parameters = CaseInsensitiveDict({**server_parameters, **keep_values})
|
||||
|
||||
def setup_server_parameters(self) -> None:
|
||||
self._server_parameters = self.get_server_parameters(self._config)
|
||||
@@ -956,7 +992,7 @@ class ConfigHandler(object):
|
||||
wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB')
|
||||
parameters.setdefault('wal_keep_segments', int(((wal_keep_size or 0) + 8) / 16))
|
||||
|
||||
self._postgresql.citus_handler.adjust_postgres_gucs(parameters)
|
||||
self._postgresql.mpp_handler.adjust_postgres_gucs(parameters)
|
||||
|
||||
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._postgresql.major_version
|
||||
or self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
|
||||
@@ -1065,13 +1101,15 @@ class ConfigHandler(object):
|
||||
def reload_config(self, config: Dict[str, Any], sighup: bool = False) -> None:
|
||||
self._superuser = config['authentication'].get('superuser', {})
|
||||
server_parameters = self.get_server_parameters(config)
|
||||
params_skip_changes = CaseInsensitiveSet((*self._RECOVERY_PARAMETERS, 'hot_standby', 'wal_log_hints'))
|
||||
|
||||
conf_changed = hba_changed = ident_changed = local_connection_address_changed = pending_restart = False
|
||||
conf_changed = hba_changed = ident_changed = local_connection_address_changed = False
|
||||
param_diff = CaseInsensitiveDict()
|
||||
if self._postgresql.state == 'running':
|
||||
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items()
|
||||
if p.lower() not in self._RECOVERY_PARAMETERS})
|
||||
if p not in params_skip_changes})
|
||||
changes.update({p: None for p in self._server_parameters.keys()
|
||||
if not (p in changes or p.lower() in self._RECOVERY_PARAMETERS)})
|
||||
if not (p in changes or p in params_skip_changes)})
|
||||
if changes:
|
||||
undef = []
|
||||
if 'wal_buffers' in changes: # we need to calculate the default value of wal_buffers
|
||||
@@ -1090,26 +1128,28 @@ class ConfigHandler(object):
|
||||
if new_value is None or not compare_values(r[3], r[2], r[1], new_value):
|
||||
conf_changed = True
|
||||
if r[4] == 'postmaster':
|
||||
pending_restart = True
|
||||
logger.info('Changed %s from %s to %s (restart might be required)',
|
||||
r[0], r[1], new_value)
|
||||
param_diff[r[0]] = get_param_diff(r[1], new_value, r[3], r[2])
|
||||
logger.info("Changed %s from '%s' to '%s' (restart might be required)",
|
||||
r[0], param_diff[r[0]]['old_value'], new_value)
|
||||
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
|
||||
or r[0] in ('listen_addresses', 'port'):
|
||||
local_connection_address_changed = True
|
||||
else:
|
||||
logger.info('Changed %s from %s to %s', r[0], r[1], new_value)
|
||||
logger.info("Changed %s from '%s' to '%s'",
|
||||
r[0], maybe_convert_from_base_unit(r[1], r[3], r[2]), 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])
|
||||
logger.info("Changed %s from '%s' to '%s'", r[0], self._server_parameters[r[0]], new_value)
|
||||
conf_changed = True
|
||||
for param, value in changes.items():
|
||||
if '.' in param:
|
||||
# Check that user-defined-paramters have changed (parameters with period in name)
|
||||
# Check that user-defined-parameters have changed (parameters with period in name)
|
||||
if value is None or param not in self._server_parameters \
|
||||
or str(value) != str(self._server_parameters[param]):
|
||||
logger.info('Changed %s from %s to %s', param, self._server_parameters.get(param), value)
|
||||
logger.info("Changed %s from '%s' to '%s'",
|
||||
param, self._server_parameters.get(param), value)
|
||||
conf_changed = True
|
||||
elif param in server_parameters:
|
||||
logger.warning('Removing invalid parameter `%s` from postgresql.parameters', param)
|
||||
@@ -1124,7 +1164,6 @@ class ConfigHandler(object):
|
||||
ident_changed = self._config.get('pg_ident', []) != config['pg_ident']
|
||||
|
||||
self._config = config
|
||||
self._postgresql.set_pending_restart(pending_restart)
|
||||
self._server_parameters = server_parameters
|
||||
self._adjust_recovery_parameters()
|
||||
self._krbsrvname = config.get('krbsrvname')
|
||||
@@ -1154,16 +1193,28 @@ class ConfigHandler(object):
|
||||
if self._postgresql.major_version >= 90500:
|
||||
time.sleep(1)
|
||||
try:
|
||||
pending_restart = self._postgresql.query(
|
||||
'SELECT COUNT(*) FROM pg_catalog.pg_settings'
|
||||
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
|
||||
[n.lower() for n in self._RECOVERY_PARAMETERS])[0][0] > 0
|
||||
self._postgresql.set_pending_restart(pending_restart)
|
||||
settings_diff: CaseInsensitiveDict = CaseInsensitiveDict()
|
||||
for param, value, unit, vartype in self._postgresql.query(
|
||||
'SELECT name, pg_catalog.current_setting(name), unit, vartype FROM pg_catalog.pg_settings'
|
||||
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
|
||||
[n.lower() for n in params_skip_changes]):
|
||||
new_value = self._postgresql.get_guc_value(param)
|
||||
new_value = '?' if new_value is None else new_value
|
||||
settings_diff[param] = get_param_diff(value, new_value, vartype, unit)
|
||||
external_change = {param: value for param, value in settings_diff.items()
|
||||
if param not in param_diff or value != param_diff[param]}
|
||||
if external_change:
|
||||
logger.info("PostgreSQL configuration parameters requiring restart"
|
||||
" (%s) seem to be changed bypassing Patroni config."
|
||||
" Setting 'Pending restart' flag", ', '.join(external_change))
|
||||
param_diff = settings_diff
|
||||
except Exception as e:
|
||||
logger.warning('Exception %r when running query', e)
|
||||
else:
|
||||
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
|
||||
|
||||
self._postgresql.set_pending_restart_reason(param_diff)
|
||||
|
||||
def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]:
|
||||
"""Updates synchronous_standby_names and reloads if necessary.
|
||||
:returns: True if value was updated."""
|
||||
@@ -1206,6 +1257,7 @@ class ConfigHandler(object):
|
||||
data = self._postgresql.controldata()
|
||||
effective_configuration = self._server_parameters.copy()
|
||||
|
||||
param_diff = CaseInsensitiveDict()
|
||||
for name, cname in options_mapping.items():
|
||||
value = parse_int(effective_configuration[name])
|
||||
if cname not in data:
|
||||
@@ -1215,7 +1267,10 @@ class ConfigHandler(object):
|
||||
cvalue = parse_int(data[cname])
|
||||
if cvalue is not None and value is not None and cvalue > value:
|
||||
effective_configuration[name] = cvalue
|
||||
self._postgresql.set_pending_restart(True)
|
||||
logger.info("%s value in pg_controldata: %d, in the global configuration: %d."
|
||||
" pg_controldata value will be used. Setting 'Pending restart' flag", name, cvalue, value)
|
||||
param_diff[name] = get_param_diff(cvalue, value)
|
||||
self._postgresql.set_pending_restart_reason(param_diff)
|
||||
|
||||
# If we are using custom bootstrap with PITR it could fail when values like max_connections
|
||||
# are increased, therefore we disable hot_standby if recovery_target_action == 'promote'.
|
||||
@@ -1232,7 +1287,6 @@ class ConfigHandler(object):
|
||||
|
||||
if disable_hot_standby:
|
||||
effective_configuration['hot_standby'] = 'off'
|
||||
self._postgresql.set_pending_restart(True)
|
||||
|
||||
return effective_configuration
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""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."""
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""The type of the MPP cluster.
|
||||
|
||||
:returns: A string representation of the type of a given MPP implementation.
|
||||
"""
|
||||
for base in self.__class__.__bases__:
|
||||
if not base.__name__.startswith('Abstract'):
|
||||
return base.__name__
|
||||
return self.__class__.__name__
|
||||
|
||||
@property
|
||||
def k8s_group_label(self):
|
||||
"""Group label used for kubernetes DCS of the MPP cluster.
|
||||
|
||||
:returns: A string representation of the k8s group label of a given MPP implementation.
|
||||
"""
|
||||
return self.type.lower() + '-group'
|
||||
|
||||
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, ProgrammingError
|
||||
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,19 +116,11 @@ 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
|
||||
|
||||
@@ -134,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
|
||||
@@ -296,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
|
||||
@@ -353,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:
|
||||
@@ -364,6 +392,11 @@ class CitusHandler(Thread):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('CREATE DATABASE {0}'.format(
|
||||
quote_ident(self._config['database'], conn)).encode('utf-8'))
|
||||
except ProgrammingError as exc:
|
||||
if exc.diag.sqlstate == '42P04': # DuplicateDatabase
|
||||
logger.debug('Exception when creating database: %r', exc)
|
||||
else:
|
||||
raise exc
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -371,7 +404,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}
|
||||
@@ -388,9 +421,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',
|
||||
@@ -408,8 +442,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:
|
||||
@@ -302,7 +302,7 @@ class SlotsHandler:
|
||||
for a in ('database', 'plugin', 'type'))
|
||||
):
|
||||
return True
|
||||
return self._postgresql.citus_handler.ignore_replication_slot(slot)
|
||||
return self._postgresql.mpp_handler.ignore_replication_slot(slot)
|
||||
|
||||
def drop_replication_slot(self, name: str) -> Tuple[bool, bool]:
|
||||
"""Drop a named slot from Postgres.
|
||||
|
||||
+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
|
||||
|
||||
+176
-24
@@ -10,6 +10,7 @@
|
||||
:var WHITESPACE_RE: regular expression to match whitespace characters
|
||||
"""
|
||||
import errno
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
@@ -24,6 +25,7 @@ from shlex import split
|
||||
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||
|
||||
from collections import OrderedDict
|
||||
from dateutil import tz
|
||||
from json import JSONDecoder
|
||||
from urllib3.response import HTTPResponse
|
||||
@@ -46,6 +48,37 @@ DBL_RE = re.compile(r'^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?')
|
||||
WHITESPACE_RE = re.compile(r'[ \t\n\r]*', re.VERBOSE | re.MULTILINE | re.DOTALL)
|
||||
|
||||
|
||||
def get_conversion_table(base_unit: str) -> Dict[str, Dict[str, Union[int, float]]]:
|
||||
"""Get conversion table for the specified base unit.
|
||||
|
||||
If no conversion table exists for the passed unit, return an empty :class:`OrderedDict`.
|
||||
|
||||
:param base_unit: unit to choose the conversion table for.
|
||||
|
||||
:returns: :class:`OrderedDict` object.
|
||||
"""
|
||||
memory_unit_conversion_table: Dict[str, Dict[str, Union[int, float]]] = OrderedDict([
|
||||
('TB', {'B': 1024**4, 'kB': 1024**3, 'MB': 1024**2}),
|
||||
('GB', {'B': 1024**3, 'kB': 1024**2, 'MB': 1024}),
|
||||
('MB', {'B': 1024**2, 'kB': 1024, 'MB': 1}),
|
||||
('kB', {'B': 1024, 'kB': 1, 'MB': 1024**-1}),
|
||||
('B', {'B': 1, 'kB': 1024**-1, 'MB': 1024**-2})
|
||||
])
|
||||
time_unit_conversion_table: Dict[str, Dict[str, Union[int, float]]] = OrderedDict([
|
||||
('d', {'ms': 1000 * 60**2 * 24, 's': 60**2 * 24, 'min': 60 * 24}),
|
||||
('h', {'ms': 1000 * 60**2, 's': 60**2, 'min': 60}),
|
||||
('min', {'ms': 1000 * 60, 's': 60, 'min': 1}),
|
||||
('s', {'ms': 1000, 's': 1, 'min': 60**-1}),
|
||||
('ms', {'ms': 1, 's': 1000**-1, 'min': 1 / (1000 * 60)}),
|
||||
('us', {'ms': 1000**-1, 's': 1000**-2, 'min': 1 / (1000**2 * 60)})
|
||||
])
|
||||
if base_unit in ('B', 'kB', 'MB'):
|
||||
return memory_unit_conversion_table
|
||||
elif base_unit in ('ms', 's', 'min'):
|
||||
return time_unit_conversion_table
|
||||
return OrderedDict()
|
||||
|
||||
|
||||
def deep_compare(obj1: Dict[Any, Union[Any, Dict[Any, Any]]], obj2: Dict[Any, Union[Any, Dict[Any, Any]]]) -> bool:
|
||||
"""Recursively compare two dictionaries to check if they are equal in terms of keys and values.
|
||||
|
||||
@@ -272,35 +305,154 @@ def convert_to_base_unit(value: Union[int, float], unit: str, base_unit: Optiona
|
||||
>>> convert_to_base_unit(1, 'GB', '512 MB') is None
|
||||
True
|
||||
"""
|
||||
convert: Dict[str, Dict[str, Union[int, float]]] = {
|
||||
'B': {'B': 1, 'kB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024},
|
||||
'kB': {'B': 1.0 / 1024, 'kB': 1, 'MB': 1024, 'GB': 1024 * 1024, 'TB': 1024 * 1024 * 1024},
|
||||
'MB': {'B': 1.0 / (1024 * 1024), 'kB': 1.0 / 1024, 'MB': 1, 'GB': 1024, 'TB': 1024 * 1024},
|
||||
'ms': {'us': 1.0 / 1000, 'ms': 1, 's': 1000, 'min': 1000 * 60, 'h': 1000 * 60 * 60, 'd': 1000 * 60 * 60 * 24},
|
||||
's': {'us': 1.0 / (1000 * 1000), 'ms': 1.0 / 1000, 's': 1, 'min': 60, 'h': 60 * 60, 'd': 60 * 60 * 24},
|
||||
'min': {'us': 1.0 / (1000 * 1000 * 60), 'ms': 1.0 / (1000 * 60), 's': 1.0 / 60, 'min': 1, 'h': 60, 'd': 60 * 24}
|
||||
}
|
||||
|
||||
round_order = {
|
||||
'TB': 'GB', 'GB': 'MB', 'MB': 'kB', 'kB': 'B',
|
||||
'd': 'h', 'h': 'min', 'min': 's', 's': 'ms', 'ms': 'us'
|
||||
}
|
||||
|
||||
if base_unit and base_unit not in convert:
|
||||
base_value, base_unit = strtol(base_unit, False)
|
||||
else:
|
||||
base_value = 1
|
||||
|
||||
if base_value is not None and base_unit in convert and unit in convert[base_unit]:
|
||||
value *= convert[base_unit][unit] / float(base_value)
|
||||
base_value, base_unit = strtol(base_unit, False)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(base_value, int)
|
||||
|
||||
convert_tbl = get_conversion_table(base_unit)
|
||||
# {'TB': 'GB', 'GB': 'MB', ...}
|
||||
round_order = dict(zip(convert_tbl, itertools.islice(convert_tbl, 1, None)))
|
||||
if unit in convert_tbl and base_unit in convert_tbl[unit]:
|
||||
value *= convert_tbl[unit][base_unit] / float(base_value)
|
||||
if unit in round_order:
|
||||
multiplier = convert[base_unit][round_order[unit]]
|
||||
multiplier = convert_tbl[round_order[unit]][base_unit]
|
||||
value = round(value / float(multiplier)) * multiplier
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def convert_int_from_base_unit(base_value: int, base_unit: Optional[str]) -> Optional[str]:
|
||||
"""Convert an integer value in some base unit to a human-friendly unit.
|
||||
|
||||
The output unit is chosen so that it's the greatest unit that can represent
|
||||
the value without loss.
|
||||
|
||||
:param base_value: value to be converted from a base unit
|
||||
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
|
||||
|
||||
* For space: ``B``, ``kB``, ``MB``;
|
||||
* For time: ``ms``, ``s``, ``min``.
|
||||
|
||||
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
|
||||
possible human-friendly unit, or ``None`` if conversion failed.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> convert_int_from_base_unit(1024, 'kB')
|
||||
'1MB'
|
||||
|
||||
>>> convert_int_from_base_unit(1025, 'kB')
|
||||
'1025kB'
|
||||
|
||||
>>> convert_int_from_base_unit(4, '256MB')
|
||||
'1GB'
|
||||
|
||||
>>> convert_int_from_base_unit(4, '256 MB') is None
|
||||
True
|
||||
|
||||
>>> convert_int_from_base_unit(1024, 'KB') is None
|
||||
True
|
||||
"""
|
||||
base_value_mult, base_unit = strtol(base_unit, False)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(base_value_mult, int)
|
||||
base_value *= base_value_mult
|
||||
|
||||
convert_tbl = get_conversion_table(base_unit)
|
||||
for unit in convert_tbl:
|
||||
multiplier = convert_tbl[unit][base_unit]
|
||||
if multiplier <= 1.0 or base_value % multiplier == 0:
|
||||
return str(round(base_value / multiplier)) + unit
|
||||
|
||||
|
||||
def convert_real_from_base_unit(base_value: float, base_unit: Optional[str]) -> Optional[str]:
|
||||
"""Convert an floating-point value in some base unit to a human-friendly unit.
|
||||
|
||||
Same as :func:`convert_int_from_base_unit`, except we have to do the math a bit differently,
|
||||
and there's a possibility that we don't find any exact divisor.
|
||||
|
||||
:param base_value: value to be converted from a base unit
|
||||
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
|
||||
|
||||
* For space: ``B``, ``kB``, ``MB``;
|
||||
* For time: ``ms``, ``s``, ``min``.
|
||||
|
||||
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
|
||||
possible human-friendly unit, or ``None`` if conversion failed.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> convert_real_from_base_unit(5, 'ms')
|
||||
'5ms'
|
||||
|
||||
>>> convert_real_from_base_unit(2.5, 'ms')
|
||||
'2500us'
|
||||
|
||||
>>> convert_real_from_base_unit(4.0, '256MB')
|
||||
'1GB'
|
||||
|
||||
>>> convert_real_from_base_unit(4.0, '256 MB') is None
|
||||
True
|
||||
"""
|
||||
base_value_mult, base_unit = strtol(base_unit, False)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(base_value_mult, int)
|
||||
base_value *= base_value_mult
|
||||
|
||||
result = None
|
||||
convert_tbl = get_conversion_table(base_unit)
|
||||
for unit in convert_tbl:
|
||||
value = base_value / convert_tbl[unit][base_unit]
|
||||
result = f'{value:g}{unit}'
|
||||
if value > 0 and abs((round(value) / value) - 1.0) <= 1e-8:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def maybe_convert_from_base_unit(base_value: str, vartype: str, base_unit: Optional[str]) -> str:
|
||||
"""Try to convert integer or real value in a base unit to a human-readable unit.
|
||||
|
||||
Value is passed as a string. If parsing or subsequent conversion fails, the original
|
||||
value is returned.
|
||||
|
||||
:param base_value: value to be converted from a base unit.
|
||||
:param vartype: the target type to parse *base_value* before converting (``integer``
|
||||
or ``real`` is expected, any other type results in return value being equal to the
|
||||
*base_value* string).
|
||||
:param base_unit: unit of *value*. Should be one of the base units (case sensitive):
|
||||
|
||||
* For space: ``B``, ``kB``, ``MB``;
|
||||
* For time: ``ms``, ``s``, ``min``.
|
||||
|
||||
:returns: :class:`str` value representing *base_value* converted from *base_unit* to the greatest
|
||||
possible human-friendly unit, or *base_value* string if conversion failed.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> maybe_convert_from_base_unit('5', 'integer', 'ms')
|
||||
'5ms'
|
||||
|
||||
>>> maybe_convert_from_base_unit('4.2', 'real', 'ms')
|
||||
'4200us'
|
||||
|
||||
>>> maybe_convert_from_base_unit('on', 'bool', None)
|
||||
'on'
|
||||
|
||||
>>> maybe_convert_from_base_unit('', 'integer', '256MB')
|
||||
''
|
||||
"""
|
||||
converters: Dict[str, Tuple[Callable[[str, Optional[str]], Union[int, float, str, None]],
|
||||
Callable[[Any, Optional[str]], Optional[str]]]] = {
|
||||
'integer': (parse_int, convert_int_from_base_unit),
|
||||
'real': (parse_real, convert_real_from_base_unit),
|
||||
'default': (lambda v, _: v, lambda v, _: v)
|
||||
}
|
||||
parser, converter = converters.get(vartype, converters['default'])
|
||||
parsed_value = parser(base_value, None)
|
||||
if parsed_value:
|
||||
return converter(parsed_value, base_unit) or base_value
|
||||
return base_value
|
||||
|
||||
|
||||
def parse_int(value: Any, base_unit: Optional[str] = None) -> Optional[int]:
|
||||
"""Parse *value* as an :class:`int`.
|
||||
|
||||
@@ -813,7 +965,7 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
|
||||
member['host'] = conn_kwargs['host']
|
||||
if conn_kwargs.get('port'):
|
||||
member['port'] = int(conn_kwargs['port'])
|
||||
optional_attributes = ('timeline', 'pending_restart', 'scheduled_restart', 'tags')
|
||||
optional_attributes = ('timeline', 'pending_restart', 'pending_restart_reason', 'scheduled_restart', 'tags')
|
||||
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
|
||||
|
||||
if m.name != leader_name:
|
||||
|
||||
@@ -16,6 +16,49 @@ from .collections import CaseInsensitiveSet
|
||||
from .dcs import dcs_modules
|
||||
from .exceptions import ConfigParseError
|
||||
from .utils import parse_int, split_host_port, data_directory_is_empty, get_major_version
|
||||
from .log import type_logformat
|
||||
|
||||
|
||||
def validate_log_field(field: Union[str, Dict[str, Any], Any]) -> bool:
|
||||
"""Checks if log field is valid.
|
||||
|
||||
:param field: A log field to be validated.
|
||||
|
||||
:returns: ``True`` if the field is either a string or a dictionary with exactly one key
|
||||
that has string value, ``False`` otherwise.
|
||||
"""
|
||||
if isinstance(field, str):
|
||||
return True
|
||||
elif isinstance(field, dict):
|
||||
return len(field) == 1 and isinstance(next(iter(field.values())), str)
|
||||
return False
|
||||
|
||||
|
||||
def validate_log_format(logformat: type_logformat) -> bool:
|
||||
"""Checks if log format is valid.
|
||||
|
||||
:param logformat: A log format to be validated.
|
||||
|
||||
:returns: ``True`` if the log format is either a string or a list of valid log fields.
|
||||
|
||||
:raises:
|
||||
:exc:`~patroni.exceptions.ConfigParseError`:
|
||||
* If the logformat is not a string or a list; or
|
||||
* If the logformat is an empty list; or
|
||||
* If the log format is a list and it with values that don't pass validation using
|
||||
:func:`validate_log_field`.
|
||||
"""
|
||||
if isinstance(logformat, str):
|
||||
return True
|
||||
elif isinstance(logformat, list):
|
||||
if len(logformat) == 0:
|
||||
raise ConfigParseError('should contain at least one item')
|
||||
if not all(map(validate_log_field, logformat)):
|
||||
raise ConfigParseError('each item should be a string or a dictionary with string values')
|
||||
|
||||
return True
|
||||
else:
|
||||
raise ConfigParseError('Should be a string or a list')
|
||||
|
||||
|
||||
def data_directory_empty(data_dir: str) -> bool:
|
||||
@@ -937,6 +980,20 @@ validate_etcd = {
|
||||
schema = Schema({
|
||||
"name": str,
|
||||
"scope": str,
|
||||
Optional("log"): {
|
||||
Optional("type"): EnumValidator(('plain', 'json'), case_sensitive=True, raise_assert=True),
|
||||
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"): validate_log_format,
|
||||
Optional("dateformat"): str,
|
||||
Optional("static_fields"): dict,
|
||||
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.1'
|
||||
__version__ = '3.2.2'
|
||||
|
||||
@@ -11,3 +11,4 @@ pysyncobj>=0.3.8
|
||||
cryptography>=1.4
|
||||
psutil>=2.0.0
|
||||
ydiff>=1.2.0
|
||||
python-json-logger>=2.0.2
|
||||
|
||||
@@ -25,7 +25,7 @@ KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
|
||||
|
||||
EXTRAS_REQUIRE = {'aws': ['boto3'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
|
||||
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
|
||||
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
|
||||
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography'], 'jsonlogger': ['python-json-logger']}
|
||||
|
||||
# Add here all kinds of additional classifiers as defined under
|
||||
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
|
||||
|
||||
+25
-23
@@ -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
|
||||
|
||||
|
||||
@@ -54,10 +55,10 @@ GET_PG_SETTINGS_RESULT = [
|
||||
('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'),
|
||||
('wal_log_hints', 'on', None, 'bool', 'postmaster'),
|
||||
('hot_standby', 'on', None, 'bool', 'postmaster'),
|
||||
('max_replication_slots', '5', None, 'integer', 'postmaster'),
|
||||
('wal_level', 'logical', None, 'enum', 'postmaster'),
|
||||
]
|
||||
|
||||
|
||||
@@ -150,6 +151,8 @@ class MockCursor(object):
|
||||
self.results = [(False, 2)]
|
||||
elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'):
|
||||
self.results = [(datetime.datetime.now(tzutc),)]
|
||||
elif sql.endswith('AND pending_restart'):
|
||||
self.results = []
|
||||
elif sql.startswith('SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings'):
|
||||
self.results = [('data_directory', 'data'),
|
||||
('hba_file', os.path.join('data', 'pg_hba.conf')),
|
||||
@@ -167,8 +170,6 @@ class MockCursor(object):
|
||||
('cluster_name', 'my_cluster')]
|
||||
elif sql.startswith('SELECT name, setting'):
|
||||
self.results = GET_PG_SETTINGS_RESULT
|
||||
elif sql.startswith('SELECT COUNT(*) FROM pg_catalog.pg_settings'):
|
||||
self.results = [(0,)]
|
||||
elif sql.startswith('IDENTIFY_SYSTEM'):
|
||||
self.results = [('1', 3, '0/402EEC0', '')]
|
||||
elif sql.startswith('TIMELINE_HISTORY '):
|
||||
@@ -252,23 +253,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):
|
||||
|
||||
+10
-2
@@ -13,6 +13,7 @@ from patroni.api import RestApiHandler, RestApiServer
|
||||
from patroni.dcs import ClusterConfig, Member
|
||||
from patroni.exceptions import PostgresConnectionException
|
||||
from patroni.ha import _MemberStatus
|
||||
from patroni.postgresql.config import get_param_diff
|
||||
from patroni.psycopg import OperationalError
|
||||
from patroni.utils import RetryFailedError, tzutc
|
||||
|
||||
@@ -54,13 +55,13 @@ class MockPostgresql:
|
||||
major_version = 90600
|
||||
sysid = 'dummysysid'
|
||||
scope = 'dummy'
|
||||
pending_restart = True
|
||||
pending_restart_reason = {}
|
||||
wal_name = 'wal'
|
||||
lsn_name = 'lsn'
|
||||
wal_flush = '_flush'
|
||||
POSTMASTER_START_TIME = 'pg_catalog.pg_postmaster_start_time()'
|
||||
TL_LSN = 'CASE WHEN pg_catalog.pg_is_in_recovery()'
|
||||
citus_handler = Mock()
|
||||
mpp_handler = Mock()
|
||||
|
||||
@staticmethod
|
||||
def postmaster_start_time():
|
||||
@@ -202,6 +203,7 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
|
||||
|
||||
def test_do_GET(self):
|
||||
MockPostgresql.pending_restart_reason = {'max_connections': get_param_diff('200', '100')}
|
||||
MockPatroni.dcs.cluster.last_lsn = 20
|
||||
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
|
||||
with patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True)):
|
||||
@@ -673,6 +675,12 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
MockRestApiServer(RestApiHandler, post + '0\n\n')
|
||||
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
|
||||
|
||||
@patch.object(MockHa, 'is_leader', Mock(return_value=True))
|
||||
def test_do_POST_mpp(self):
|
||||
post = 'POST /mpp HTTP/1.0' + self._authorization + '\nContent-Length: '
|
||||
MockRestApiServer(RestApiHandler, post + '0\n\n')
|
||||
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
|
||||
|
||||
|
||||
class TestRestApiServer(unittest.TestCase):
|
||||
|
||||
|
||||
+15
-3
@@ -4,10 +4,11 @@ import sys
|
||||
from mock import Mock, PropertyMock, patch
|
||||
|
||||
from patroni.async_executor import CriticalTask
|
||||
from patroni.collections import CaseInsensitiveDict
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.bootstrap import Bootstrap
|
||||
from patroni.postgresql.cancellable import CancellableSubprocess
|
||||
from patroni.postgresql.config import ConfigHandler
|
||||
from patroni.postgresql.config import ConfigHandler, get_param_diff
|
||||
|
||||
from . import psycopg_connect, BaseTestPostgresql, mock_available_gucs
|
||||
|
||||
@@ -142,6 +143,16 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
(), error_handler
|
||||
),
|
||||
["--key=value with spaces"])
|
||||
# not allowed options in list of dicts/strs are filtered out
|
||||
self.assertEqual(
|
||||
self.b.process_user_options(
|
||||
'pg_basebackup',
|
||||
[{'checkpoint': 'fast'}, {'dbname': 'dbname=postgres'}, 'gzip', {'label': 'standby'}, 'verbose'],
|
||||
('dbname', 'verbose'),
|
||||
print
|
||||
),
|
||||
['--checkpoint=fast', '--gzip', '--label=standby'],
|
||||
)
|
||||
|
||||
@patch.object(CancellableSubprocess, 'call', Mock())
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
@@ -235,8 +246,9 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
self.assertTrue(task.result)
|
||||
|
||||
self.b.bootstrap(config)
|
||||
with patch.object(Postgresql, 'pending_restart', PropertyMock(return_value=True)), \
|
||||
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
|
||||
with patch.object(Postgresql, 'pending_restart_reason',
|
||||
PropertyMock(CaseInsensitiveDict({'max_connections': get_param_diff('200', '100')}))), \
|
||||
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
|
||||
self.b.post_bootstrap({}, task)
|
||||
mock_restart.assert_called_once()
|
||||
|
||||
|
||||
+31
-21
@@ -1,25 +1,26 @@
|
||||
import time
|
||||
from mock import Mock, patch
|
||||
from patroni.postgresql.citus import CitusHandler
|
||||
from mock import Mock, patch, PropertyMock
|
||||
from patroni.postgresql.mpp.citus import CitusHandler
|
||||
from patroni.psycopg import ProgrammingError
|
||||
|
||||
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):
|
||||
|
||||
def setUp(self):
|
||||
super(TestCitus, self).setUp()
|
||||
self.c = self.p.citus_handler
|
||||
self.c = self.p.mpp_handler
|
||||
self.cluster = get_cluster_initialized_with_leader()
|
||||
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 +40,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 +52,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 +107,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 +116,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 +141,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 +158,16 @@ 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):
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=ProgrammingError)):
|
||||
self.assertRaises(ProgrammingError, self.c.bootstrap)
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=[ProgrammingError, None, None, None])), \
|
||||
patch.object(ProgrammingError, 'diag') as mock_diag:
|
||||
type(mock_diag).sqlstate = PropertyMock(return_value='42P04')
|
||||
self.c.bootstrap()
|
||||
mock_logger.assert_called_once()
|
||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Exception when creating database'))
|
||||
|
||||
+26
-20
@@ -35,6 +35,7 @@ class TestConfig(unittest.TestCase):
|
||||
'PATRONI_NAMESPACE': '/patroni/',
|
||||
'PATRONI_SCOPE': 'batman2',
|
||||
'PATRONI_LOGLEVEL': 'ERROR',
|
||||
'PATRONI_LOG_FORMAT': '["message", {"levelname": "level"}]',
|
||||
'PATRONI_LOG_LOGGERS': 'patroni.postmaster: WARNING, urllib3: DEBUG',
|
||||
'PATRONI_LOG_FILE_NUM': '5',
|
||||
'PATRONI_CITUS_DATABASE': 'citus',
|
||||
@@ -155,38 +156,43 @@ class TestConfig(unittest.TestCase):
|
||||
def test_invalid_path(self):
|
||||
self.assertRaises(ConfigParseError, Config, 'postgres0')
|
||||
|
||||
@patch.object(Config, 'get')
|
||||
@patch('patroni.config.logger')
|
||||
def test__validate_failover_tags(self, mock_logger):
|
||||
def test__validate_failover_tags(self, mock_logger, mock_get):
|
||||
"""Ensures that only one of `nofailover` or `failover_priority` can be provided"""
|
||||
config = Config("postgres0.yml")
|
||||
|
||||
# Providing one of `nofailover` or `failover_priority` is fine
|
||||
for tags_config in [{"nofailover": True}, {"failover_priority": 1}]:
|
||||
self.assertIsNone(Config._validate_failover_tags(tags_config))
|
||||
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
|
||||
for tags_config in [
|
||||
{"nofailover": False, "failover_priority": 1},
|
||||
{"nofailover": True, "failover_priority": 0}]:
|
||||
self.assertIsNone(Config._validate_failover_tags(tags_config))
|
||||
self.assertIn('nofailover', tags_config)
|
||||
self.assertIn('failover_priority', tags_config)
|
||||
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
|
||||
for tags_config in [
|
||||
{"nofailover": False, "failover_priority": 0},
|
||||
{"nofailover": True, "failover_priority": 1}]:
|
||||
initial_config = tags_config.copy()
|
||||
self.assertIsNone(Config._validate_failover_tags(tags_config))
|
||||
self.assertIn('nofailover', tags_config)
|
||||
self.assertNotIn('failover_priority', tags_config)
|
||||
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',
|
||||
initial_config['nofailover'],
|
||||
initial_config['failover_priority'],
|
||||
initial_config['nofailover']
|
||||
)
|
||||
inconsistent_state['nofailover'],
|
||||
inconsistent_state['failover_priority'],
|
||||
inconsistent_state['nofailover'])
|
||||
mock_logger.warning.reset_mock()
|
||||
|
||||
def test__process_postgresql_parameters(self):
|
||||
|
||||
@@ -62,9 +62,10 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
'scope': self.environ['PATRONI_SCOPE'],
|
||||
'name': HOSTNAME,
|
||||
'log': {
|
||||
'type': PatroniLogger.DEFAULT_TYPE,
|
||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||
'level': PatroniLogger.DEFAULT_LEVEL,
|
||||
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
|
||||
},
|
||||
'restapi': {
|
||||
|
||||
+24
-18
@@ -3,8 +3,10 @@ import unittest
|
||||
|
||||
from consul import ConsulException, NotFound
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
|
||||
from patroni.dcs import get_dcs
|
||||
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulAgentService, 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)
|
||||
@@ -239,8 +245,8 @@ class TestConsul(unittest.TestCase):
|
||||
def test_set_history_value(self):
|
||||
self.assertTrue(self.c.set_history_value('{}'))
|
||||
|
||||
@patch.object(consul.Consul.Agent.Service, 'register', Mock(side_effect=(False, True, True, True)))
|
||||
@patch.object(consul.Consul.Agent.Service, 'deregister', Mock(return_value=True))
|
||||
@patch.object(ConsulAgentService, 'register', Mock(side_effect=(False, True, True, True)))
|
||||
@patch.object(ConsulAgentService, 'deregister', Mock(return_value=True))
|
||||
def test_update_service(self):
|
||||
d = {'role': 'replica', 'api_url': 'http://a/t', 'conn_url': 'pg://c:1', 'state': 'running'}
|
||||
self.assertIsNone(self.c.update_service({}, {}))
|
||||
@@ -271,7 +277,7 @@ class TestConsul(unittest.TestCase):
|
||||
|
||||
# Changing register_service from True to False calls deregister()
|
||||
self.c.reload_config({'consul': {'register_service': False}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.deregister') as mock_deregister:
|
||||
with patch.object(ConsulAgentService, 'deregister') as mock_deregister:
|
||||
self.c.touch_member(d)
|
||||
mock_deregister.assert_called_once()
|
||||
|
||||
@@ -279,31 +285,31 @@ class TestConsul(unittest.TestCase):
|
||||
|
||||
# register_service staying False between reloads does not call deregister()
|
||||
self.c.reload_config({'consul': {'register_service': False}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.deregister') as mock_deregister:
|
||||
with patch.object(ConsulAgentService, 'deregister') as mock_deregister:
|
||||
self.c.touch_member(d)
|
||||
self.assertFalse(mock_deregister.called)
|
||||
|
||||
# Changing register_service from False to True calls register()
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
with patch.object(HTTPClient, 'put', create=True) as mock_put:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
mock_put.assert_called_once()
|
||||
|
||||
# register_service staying True between reloads does not call register()
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
with patch.object(ConsulAgentService, 'register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
self.assertFalse(mock_deregister.called)
|
||||
|
||||
# register_service staying True between reloads does calls register() if other service data has changed
|
||||
self.c.reload_config({'consul': {'register_service': True}, 'loop_wait': 10, 'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
with patch.object(ConsulAgentService, 'register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
|
||||
# register_service staying True between reloads does calls register() if service_tags have changed
|
||||
self.c.reload_config({'consul': {'register_service': True, 'service_tags': ['foo']}, 'loop_wait': 10,
|
||||
'ttl': 30, 'retry_timeout': 10})
|
||||
with patch('consul.Consul.Agent.Service.register') as mock_register:
|
||||
with patch.object(ConsulAgentService, 'register') as mock_register:
|
||||
self.c.touch_member(d)
|
||||
mock_register.assert_called_once()
|
||||
|
||||
+29
-6
@@ -12,6 +12,8 @@ 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.config import get_param_diff
|
||||
from patroni.postgresql.mpp import get_mpp
|
||||
from patroni.psycopg import OperationalError
|
||||
from patroni.utils import tzutc
|
||||
from prettytable import PrettyTable, ALL
|
||||
@@ -69,7 +71,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 +109,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 +159,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,6 +223,11 @@ 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')
|
||||
@@ -244,7 +252,7 @@ class TestCtl(unittest.TestCase):
|
||||
@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)
|
||||
@@ -433,7 +441,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))
|
||||
|
||||
@@ -442,7 +450,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)), [])
|
||||
|
||||
@@ -475,6 +483,21 @@ class TestCtl(unittest.TestCase):
|
||||
with patch('patroni.ctl.load_config', Mock(return_value={})):
|
||||
self.runner.invoke(ctl, ['list'])
|
||||
|
||||
cluster = get_cluster_initialized_with_leader()
|
||||
cluster.members[1].data['pending_restart'] = True
|
||||
cluster.members[1].data['pending_restart_reason'] = {'param': get_param_diff('', 'very l' + 'o' * 34 + 'ng')}
|
||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)):
|
||||
for cmd in ('list', 'topology'):
|
||||
result = self.runner.invoke(ctl, [cmd, 'dummy'])
|
||||
self.assertIn('param: [hidden - too long]', result.output)
|
||||
|
||||
result = self.runner.invoke(ctl, ['list', 'dummy', '-f', 'tsv'])
|
||||
self.assertIn('param: ->very l' + 'o' * 34 + 'ng', result.output)
|
||||
|
||||
cluster.members[1].data['pending_restart_reason'] = {'param': get_param_diff('', 'new')}
|
||||
result = self.runner.invoke(ctl, ['list', 'dummy'])
|
||||
self.assertIn('param: ->new', result.output)
|
||||
|
||||
def test_list_extended(self):
|
||||
result = self.runner.invoke(ctl, ['list', 'dummy', '--extended', '--timestamp'])
|
||||
assert '2100' in result.output
|
||||
|
||||
+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))
|
||||
|
||||
+15
-14
@@ -197,7 +197,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):
|
||||
@@ -593,8 +593,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()
|
||||
@@ -615,8 +615,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()
|
||||
@@ -659,7 +659,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()
|
||||
@@ -694,7 +694,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
|
||||
|
||||
@@ -733,7 +733,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
|
||||
|
||||
@@ -774,7 +774,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
|
||||
|
||||
@@ -1544,7 +1544,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])
|
||||
@@ -1656,15 +1656,16 @@ 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')
|
||||
self.ha.notify_mpp_coordinator('before_demote')
|
||||
self.ha.patroni.request.assert_called_once()
|
||||
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 30)
|
||||
self.ha.patroni.request = Mock(side_effect=Exception)
|
||||
with patch('patroni.ha.logger.warning') as mock_logger:
|
||||
self.ha.notify_citus_coordinator('before_promote')
|
||||
self.ha.notify_mpp_coordinator('before_promote')
|
||||
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 2)
|
||||
mock_logger.assert_called()
|
||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Request to Citus coordinator'))
|
||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Request to %s coordinator leader'))
|
||||
self.assertEqual(mock_logger.call_args[0][1], 'Citus')
|
||||
|
||||
+35
-17
@@ -8,14 +8,17 @@ 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
|
||||
|
||||
|
||||
def mock_list_namespaced_config_map(*args, **kwargs):
|
||||
k8s_group_label = get_mpp({'citus': {'group': 0, 'database': 'postgres'}}).k8s_group_label
|
||||
metadata = {'resource_version': '1', 'labels': {'f': 'b'}, 'name': 'test-config',
|
||||
'annotations': {'initialize': '123', 'config': '{}'}}
|
||||
items = [k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))]
|
||||
@@ -26,16 +29,16 @@ def mock_list_namespaced_config_map(*args, **kwargs):
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
metadata.update({'name': 'test-sync', 'annotations': {'leader': 'p-0'}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
metadata.update({'name': 'test-0-leader', 'labels': {Kubernetes._CITUS_LABEL: '0'},
|
||||
metadata.update({'name': 'test-0-leader', 'labels': {k8s_group_label: '0'},
|
||||
'annotations': {'optime': '1234x', 'leader': 'p-0', 'ttl': '30s', 'slots': '{', 'failsafe': '{'}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
metadata.update({'name': 'test-0-config', 'labels': {Kubernetes._CITUS_LABEL: '0'},
|
||||
metadata.update({'name': 'test-0-config', 'labels': {k8s_group_label: '0'},
|
||||
'annotations': {'initialize': '123', 'config': '{}'}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
metadata.update({'name': 'test-1-leader', 'labels': {Kubernetes._CITUS_LABEL: '1'},
|
||||
metadata.update({'name': 'test-1-leader', 'labels': {k8s_group_label: '1'},
|
||||
'annotations': {'leader': 'p-3', 'ttl': '30s'}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
metadata.update({'name': 'test-2-config', 'labels': {Kubernetes._CITUS_LABEL: '2'}, 'annotations': {}})
|
||||
metadata.update({'name': 'test-2-config', 'labels': {k8s_group_label: '2'}, 'annotations': {}})
|
||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||
|
||||
metadata = k8s_client.V1ObjectMeta(resource_version='1')
|
||||
@@ -60,7 +63,8 @@ def mock_list_namespaced_endpoints(*args, **kwargs):
|
||||
|
||||
|
||||
def mock_list_namespaced_pod(*args, **kwargs):
|
||||
metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b', Kubernetes._CITUS_LABEL: '1'},
|
||||
k8s_group_label = get_mpp({'citus': {'group': 0, 'database': 'postgres'}}).k8s_group_label
|
||||
metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b', k8s_group_label: '1'},
|
||||
name='p-0', annotations={'status': '{}'},
|
||||
uid='964dfeae-e79b-4476-8a5a-1920b5c2a69d')
|
||||
status = k8s_client.V1PodStatus(pod_ip='10.0.0.1')
|
||||
@@ -225,11 +229,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,18 +259,31 @@ 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)
|
||||
|
||||
@patch('patroni.dcs.kubernetes.logger.error')
|
||||
def test_get_citus_coordinator(self, mock_logger):
|
||||
self.assertIsInstance(self.k.get_citus_coordinator(), Cluster)
|
||||
with patch.object(Kubernetes, '_cluster_loader', Mock(side_effect=Exception)):
|
||||
self.assertIsNone(self.k.get_citus_coordinator())
|
||||
def test_get_mpp_coordinator(self, mock_logger):
|
||||
self.assertIsInstance(self.k.get_mpp_coordinator(), Cluster)
|
||||
with patch.object(Kubernetes, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
|
||||
self.assertIsNone(self.k.get_mpp_coordinator())
|
||||
mock_logger.assert_called()
|
||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Failed to load Citus coordinator'))
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from Kubernetes: %r')
|
||||
self.assertEqual(mock_logger.call_args[0][1], 'Null')
|
||||
self.assertIsInstance(mock_logger.call_args[0][2], KubernetesError)
|
||||
|
||||
@patch('patroni.dcs.kubernetes.logger.error')
|
||||
def test_get_citus_coordinator(self, mock_logger):
|
||||
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
self.assertIsInstance(self.k.get_mpp_coordinator(), Cluster)
|
||||
with patch.object(Kubernetes, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
|
||||
self.assertIsNone(self.k.get_mpp_coordinator())
|
||||
mock_logger.assert_called()
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from Kubernetes: %r')
|
||||
self.assertEqual(mock_logger.call_args[0][1], 'Citus')
|
||||
self.assertIsInstance(mock_logger.call_args[0][2], KubernetesError)
|
||||
|
||||
def test_attempt_to_acquire_leader(self):
|
||||
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', create=True) as mock_patch:
|
||||
@@ -466,7 +484,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'}}}}
|
||||
|
||||
@@ -3,12 +3,23 @@ import os
|
||||
import sys
|
||||
import unittest
|
||||
import yaml
|
||||
from io import StringIO
|
||||
|
||||
from mock import Mock, patch
|
||||
from patroni.config import Config
|
||||
from patroni.log import PatroniLogger
|
||||
from queue import Queue, Full
|
||||
|
||||
try:
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
jsonlogger.JsonFormatter(None, None, rename_fields={}, static_fields={})
|
||||
json_formatter_is_available = True
|
||||
|
||||
import json # we need json.loads() function
|
||||
except Exception:
|
||||
json_formatter_is_available = False
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -72,3 +83,201 @@ class TestPatroniLogger(unittest.TestCase):
|
||||
_LOG.info('blabla')
|
||||
logger.shutdown()
|
||||
self.assertEqual(logger.records_lost, 0)
|
||||
|
||||
def test_json_list_format(self):
|
||||
config = {
|
||||
'type': 'json',
|
||||
'format': [
|
||||
{'asctime': '@timestamp'},
|
||||
{'levelname': 'level'},
|
||||
'message'
|
||||
],
|
||||
'static_fields': {
|
||||
'app': 'patroni'
|
||||
}
|
||||
}
|
||||
|
||||
test_message = 'test json logging in case of list format'
|
||||
|
||||
with patch('sys.stderr', StringIO()) as stderr_output:
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config(config)
|
||||
|
||||
_LOG.info(test_message)
|
||||
if json_formatter_is_available:
|
||||
target_log = json.loads(stderr_output.getvalue().split('\n')[-2])
|
||||
|
||||
self.assertIn('@timestamp', target_log)
|
||||
self.assertEqual(target_log['message'], test_message)
|
||||
self.assertEqual(target_log['level'], 'INFO')
|
||||
self.assertEqual(target_log['app'], 'patroni')
|
||||
self.assertEqual(len(target_log), len(config['format']) + len(config['static_fields']))
|
||||
|
||||
def test_json_str_format(self):
|
||||
config = {
|
||||
'type': 'json',
|
||||
'format': '%(asctime)s %(levelname)s %(message)s',
|
||||
'static_fields': {
|
||||
'app': 'patroni'
|
||||
}
|
||||
}
|
||||
|
||||
test_message = 'test json logging in case of string format'
|
||||
|
||||
with patch('sys.stderr', StringIO()) as stderr_output:
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config(config)
|
||||
|
||||
_LOG.info(test_message)
|
||||
if json_formatter_is_available:
|
||||
target_log = json.loads(stderr_output.getvalue().split('\n')[-2])
|
||||
|
||||
self.assertIn('asctime', target_log)
|
||||
self.assertEqual(target_log['message'], test_message)
|
||||
self.assertEqual(target_log['levelname'], 'INFO')
|
||||
self.assertEqual(target_log['app'], 'patroni')
|
||||
|
||||
def test_plain_format(self):
|
||||
config = {
|
||||
'type': 'plain',
|
||||
'format': '[%(asctime)s] %(levelname)s %(message)s',
|
||||
}
|
||||
|
||||
test_message = 'test plain logging'
|
||||
|
||||
with patch('sys.stderr', StringIO()) as stderr_output:
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config(config)
|
||||
|
||||
_LOG.info(test_message)
|
||||
target_log = stderr_output.getvalue()
|
||||
|
||||
self.assertRegex(target_log, fr'^\[.*\] INFO {test_message}$')
|
||||
|
||||
def test_dateformat(self):
|
||||
config = {
|
||||
'format': '[%(asctime)s] %(message)s',
|
||||
'dateformat': '%Y-%m-%dT%H:%M:%S'
|
||||
}
|
||||
|
||||
test_message = 'test date format'
|
||||
|
||||
with patch('sys.stderr', StringIO()) as stderr_output:
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config(config)
|
||||
|
||||
_LOG.info(test_message)
|
||||
target_log = stderr_output.getvalue()
|
||||
|
||||
self.assertRegex(target_log, r'\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\]')
|
||||
|
||||
def test_invalid_dateformat(self):
|
||||
config = {
|
||||
'format': '[%(asctime)s] %(message)s',
|
||||
'dateformat': 5
|
||||
}
|
||||
|
||||
with self.assertLogs() as captured_log:
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config(config)
|
||||
|
||||
captured_log_level = captured_log.records[0].levelname
|
||||
captured_log_message = captured_log.records[0].message
|
||||
|
||||
self.assertEqual(captured_log_level, 'WARNING')
|
||||
self.assertRegex(
|
||||
captured_log_message,
|
||||
fr'Expected log dateformat to be a string, but got "{type(config["dateformat"])}"'
|
||||
)
|
||||
|
||||
def test_invalid_plain_format(self):
|
||||
config = {
|
||||
'type': 'plain',
|
||||
'format': ['message']
|
||||
}
|
||||
|
||||
with self.assertLogs() as captured_log:
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config(config)
|
||||
|
||||
captured_log_level = captured_log.records[0].levelname
|
||||
captured_log_message = captured_log.records[0].message
|
||||
|
||||
self.assertEqual(captured_log_level, 'WARNING')
|
||||
self.assertRegex(
|
||||
captured_log_message,
|
||||
r'Expected log format to be a string when log type is plain, but got ".*"'
|
||||
)
|
||||
|
||||
def test_invalid_json_format(self):
|
||||
config = {
|
||||
'type': 'json',
|
||||
'format': {
|
||||
'asctime': 'timestamp',
|
||||
'message': 'message'
|
||||
}
|
||||
}
|
||||
|
||||
with self.assertLogs() as captured_log:
|
||||
logger = PatroniLogger()
|
||||
logger.reload_config(config)
|
||||
|
||||
captured_log_level = captured_log.records[0].levelname
|
||||
captured_log_message = captured_log.records[0].message
|
||||
|
||||
self.assertEqual(captured_log_level, 'WARNING')
|
||||
self.assertRegex(captured_log_message, r'Expected log format to be a string or a list, but got ".*"')
|
||||
|
||||
with self.assertLogs() as captured_log:
|
||||
config['format'] = [['levelname']]
|
||||
logger.reload_config(config)
|
||||
|
||||
captured_log_level = captured_log.records[0].levelname
|
||||
captured_log_message = captured_log.records[0].message
|
||||
|
||||
self.assertEqual(captured_log_level, 'WARNING')
|
||||
self.assertRegex(
|
||||
captured_log_message,
|
||||
r'Expected each item of log format to be a string or dictionary, but got ".*"'
|
||||
)
|
||||
|
||||
with self.assertLogs() as captured_log:
|
||||
config['format'] = ['message', {'asctime': ['timestamp']}]
|
||||
logger.reload_config(config)
|
||||
|
||||
captured_log_level = captured_log.records[0].levelname
|
||||
captured_log_message = captured_log.records[0].message
|
||||
|
||||
self.assertEqual(captured_log_level, 'WARNING')
|
||||
self.assertRegex(captured_log_message, r'Expected renamed log field to be a string, but got ".*"')
|
||||
|
||||
def test_fail_to_use_python_json_logger(self):
|
||||
with self.assertLogs() as captured_log:
|
||||
logger = PatroniLogger()
|
||||
with patch('builtins.__import__', Mock(side_effect=ImportError)):
|
||||
logger.reload_config({'type': 'json'})
|
||||
|
||||
captured_log_level = captured_log.records[0].levelname
|
||||
captured_log_message = captured_log.records[0].message
|
||||
|
||||
self.assertEqual(captured_log_level, 'ERROR')
|
||||
self.assertRegex(
|
||||
captured_log_message,
|
||||
r'Failed to import "python-json-logger" library: .*. Falling back to the plain logger'
|
||||
)
|
||||
|
||||
with self.assertLogs() as captured_log:
|
||||
logger = PatroniLogger()
|
||||
pythonjsonlogger = Mock()
|
||||
pythonjsonlogger.jsonlogger.JsonFormatter = Mock(side_effect=Exception)
|
||||
with patch('builtins.__import__', Mock(return_value=pythonjsonlogger)):
|
||||
logger.reload_config({'type': 'json'})
|
||||
|
||||
captured_log_level = captured_log.records[0].levelname
|
||||
captured_log_message = captured_log.records[0].message
|
||||
|
||||
self.assertEqual(captured_log_level, 'ERROR')
|
||||
self.assertRegex(
|
||||
captured_log_message,
|
||||
r'Failed to initialize JsonFormatter: .*. Falling back to the plain logger'
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
+101
-36
@@ -12,12 +12,13 @@ import patroni.psycopg as psycopg
|
||||
|
||||
from patroni import global_config
|
||||
from patroni.async_executor import CriticalTask
|
||||
from patroni.collections import CaseInsensitiveSet
|
||||
from patroni.collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||
from patroni.dcs import RemoteMember
|
||||
from patroni.exceptions import PostgresConnectionException, PatroniException
|
||||
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
|
||||
from patroni.postgresql.bootstrap import Bootstrap
|
||||
from patroni.postgresql.callback_executor import CallbackAction
|
||||
from patroni.postgresql.config import get_param_diff, _false_validator
|
||||
from patroni.postgresql.postmaster import PostmasterProcess
|
||||
from patroni.postgresql.validator import (ValidatorFactoryNoType, ValidatorFactoryInvalidType,
|
||||
ValidatorFactoryInvalidSpec, ValidatorFactory, InvalidGucValidatorsFile,
|
||||
@@ -570,7 +571,15 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.p.reload_config(config)
|
||||
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)
|
||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Ignored params changed
|
||||
config['parameters']['archive_cleanup_command'] = 'blabla'
|
||||
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_reason, CaseInsensitiveDict())
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
@@ -578,7 +587,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
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)
|
||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
||||
|
||||
mock_info.reset_mock()
|
||||
config = deepcopy(self.p.config._config)
|
||||
@@ -588,51 +597,60 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
config['pg_ident'] = ['']
|
||||
self.p.reload_config(config)
|
||||
mock_info.assert_called_once_with('Reloading PostgreSQL configuration.')
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
||||
|
||||
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,)]])):
|
||||
new_max_worker_processes = config['parameters']['max_worker_processes']
|
||||
# stale reason to be removed
|
||||
self.p._pending_restart_reason = CaseInsensitiveDict({'max_connections': get_param_diff('200', '100')})
|
||||
|
||||
with patch.object(Postgresql, 'get_guc_value', Mock(return_value=str(new_max_worker_processes))), \
|
||||
patch('patroni.postgresql.Postgresql._query', Mock(side_effect=[
|
||||
GET_PG_SETTINGS_RESULT, [('max_worker_processes', str(init_max_worker_processes), None, 'integer')]])):
|
||||
self.p.reload_config(config)
|
||||
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[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)
|
||||
self.assertEqual(self.p.pending_restart_reason,
|
||||
CaseInsensitiveDict({'max_worker_processes': get_param_diff(init_max_worker_processes,
|
||||
new_max_worker_processes)}))
|
||||
|
||||
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)
|
||||
self.assertEqual(mock_info.call_args_list[0][0], ('Changed %s from %s to %s', 'max_worker_processes',
|
||||
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'])))
|
||||
config['parameters']['max_worker_processes']))
|
||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
# User-defined parameter changed (removed)
|
||||
config['parameters'].pop('f.oo')
|
||||
self.p.reload_config(config)
|
||||
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[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)
|
||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
||||
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Non-postmaster parameter change
|
||||
config['parameters']['autovacuum'] = 'off'
|
||||
config['parameters']['vacuum_cost_delay'] = 2.5
|
||||
self.p.reload_config(config)
|
||||
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[0][0],
|
||||
("Changed %s from '%s' to '%s'", 'vacuum_cost_delay', '200ms', 2.5))
|
||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||
self.assertEqual(self.p.pending_restart, False)
|
||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
||||
|
||||
config['parameters']['autovacuum'] = 'on'
|
||||
config['parameters']['vacuum_cost_delay'] = 200
|
||||
mock_info.reset_mock()
|
||||
|
||||
# Remove invalid parameter
|
||||
@@ -645,13 +663,35 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
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])):
|
||||
# Non-empty result (outside changes)
|
||||
with patch.object(Postgresql, 'get_guc_value', Mock(side_effect=['73', None, ''])), \
|
||||
patch('patroni.postgresql.Postgresql._query',
|
||||
Mock(side_effect=[GET_PG_SETTINGS_RESULT, [('shared_buffers', '128MB', '8kB', 'integer')]] * 3)):
|
||||
# pg_settings shared_buffers (current value) == 128MB (16384)
|
||||
# Patroni config shared_buffers == 42MB (should not end up in the restart reason diff)
|
||||
# get_guc_value (will be used after restart) == 73 (584kB)
|
||||
config['parameters']['shared_buffers'] = '42MB'
|
||||
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)
|
||||
self.assertEqual(mock_info.call_args_list[0][0],
|
||||
("Changed %s from '%s' to '%s' (restart might be required)",
|
||||
'shared_buffers', '128MB', '42MB'))
|
||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||
self.assertEqual(mock_info.call_args_list[2][0], ("PostgreSQL configuration parameters requiring restart"
|
||||
" (%s) seem to be changed bypassing Patroni config."
|
||||
" Setting 'Pending restart' flag", 'shared_buffers'))
|
||||
self.assertEqual(self.p.pending_restart_reason,
|
||||
CaseInsensitiveDict({'shared_buffers': get_param_diff('128MB', '584kB')}))
|
||||
|
||||
self.p.reload_config(config, True)
|
||||
self.assertEqual(self.p.pending_restart_reason,
|
||||
CaseInsensitiveDict({'shared_buffers': get_param_diff('128MB', '?')}))
|
||||
|
||||
self.p.reload_config(config, True)
|
||||
self.assertEqual(self.p.pending_restart_reason,
|
||||
CaseInsensitiveDict({'shared_buffers': get_param_diff('128MB', '')}))
|
||||
|
||||
# Exception while querying pending_restart parameters
|
||||
with patch('patroni.postgresql.Postgresql._query', Mock(side_effect=[GET_PG_SETTINGS_RESULT, Exception])):
|
||||
# Invalid values, just to increase silly coverage in postgresql.validator.
|
||||
# One day we will have proper tests there.
|
||||
config['parameters']['autovacuum'] = 'of' # Bool.transform()
|
||||
@@ -800,22 +840,36 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='replica'))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=False))
|
||||
@patch.object(Bootstrap, 'running_custom_bootstrap', PropertyMock(return_value=True))
|
||||
@patch.object(Postgresql, 'controldata', Mock(return_value={'max_connections setting': '200',
|
||||
'max_worker_processes setting': '20',
|
||||
'max_locks_per_xact setting': '100',
|
||||
'max_wal_senders setting': 10}))
|
||||
@patch('patroni.postgresql.config.logger.warning')
|
||||
@patch('patroni.postgresql.config.logger')
|
||||
def test_effective_configuration(self, mock_logger):
|
||||
self.p.cancellable.cancel()
|
||||
self.p.config.write_recovery_conf({'pause_at_recovery_target': 'false'})
|
||||
self.assertFalse(self.p.start())
|
||||
mock_logger.assert_called_once()
|
||||
self.assertTrue('is missing from pg_controldata output' in mock_logger.call_args[0][0])
|
||||
controldata = {'max_connections setting': '100', 'max_worker_processes setting': '8',
|
||||
'max_locks_per_xact setting': '64', 'max_wal_senders setting': 5}
|
||||
|
||||
self.assertTrue(self.p.pending_restart)
|
||||
with patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
|
||||
with patch.object(Postgresql, 'controldata', Mock(return_value=controldata)), \
|
||||
patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
|
||||
self.p.cancellable.cancel()
|
||||
self.assertFalse(self.p.start())
|
||||
self.assertTrue(self.p.pending_restart)
|
||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
||||
mock_logger.warning.assert_called_once()
|
||||
self.assertEqual(mock_logger.warning.call_args[0],
|
||||
('%s is missing from pg_controldata output', 'max_prepared_xacts setting'))
|
||||
|
||||
mock_logger.reset_mock()
|
||||
controldata['max_prepared_xacts setting'] = 0
|
||||
controldata['max_wal_senders setting'] *= 2
|
||||
|
||||
with patch.object(Postgresql, 'controldata', Mock(return_value=controldata)):
|
||||
self.p.config.write_recovery_conf({'pause_at_recovery_target': 'false'})
|
||||
self.assertFalse(self.p.start())
|
||||
mock_logger.warning.assert_not_called()
|
||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict({
|
||||
'max_wal_senders': get_param_diff('10', '5')
|
||||
}))
|
||||
mock_logger.info.assert_called_once()
|
||||
self.assertEqual(mock_logger.info.call_args[0],
|
||||
("%s value in pg_controldata: %d, in the global configuration: %d."
|
||||
" pg_controldata value will be used. Setting 'Pending restart' flag",
|
||||
'max_wal_senders', 10, 5))
|
||||
|
||||
@patch('os.path.exists', Mock(return_value=True))
|
||||
@patch('os.path.isfile', Mock(return_value=False))
|
||||
@@ -1058,3 +1112,14 @@ class TestPostgresql2(BaseTestPostgresql):
|
||||
self.assertIn('diff(pg_catalog.pg_current_xlog_flush_location(', self.p.cluster_info_query)
|
||||
self.p._major_version = 90500
|
||||
self.assertIn('diff(pg_catalog.pg_current_xlog_location(', self.p.cluster_info_query)
|
||||
|
||||
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
||||
@patch.object(Postgresql, '_query', Mock(return_value=[('primary_conninfo', 'host=a port=5433 passfile=/blabla')]))
|
||||
def test_load_current_server_parameters(self):
|
||||
keep_values = {name: self.p.config._server_parameters[name]
|
||||
for name, value in self.p.config.CMDLINE_OPTIONS.items() if value[1] == _false_validator}
|
||||
self.p.config.load_current_server_parameters()
|
||||
self.assertTrue(all(self.p.config._server_parameters[name] == value for name, value in keep_values.items()))
|
||||
self.assertEqual(dict(self.p.config._recovery_params),
|
||||
{'primary_conninfo': {'host': 'a', 'port': '5433', 'passfile': '/blabla',
|
||||
'gssencmode': 'prefer', 'sslmode': 'prefer', 'channel_binding': 'prefer'}})
|
||||
|
||||
+13
-10
@@ -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'))
|
||||
self.assertFalse(raft.write_sync_state('foo', 'bar', 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)
|
||||
@@ -153,13 +156,13 @@ class TestRaft(unittest.TestCase):
|
||||
self.assertTrue(raft.update_leader(leader, '1', failsafe={'foo': 'bat'}))
|
||||
self.assertTrue(raft._sync_obj.set(raft.failsafe_path, '{"foo"}'))
|
||||
self.assertTrue(raft._sync_obj.set(raft.status_path, '{'))
|
||||
raft.get_citus_coordinator()
|
||||
raft.get_mpp_coordinator()
|
||||
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,21 @@ available_dcs = [m.split(".")[-1] for m in dcs_modules()]
|
||||
config = {
|
||||
"name": "string",
|
||||
"scope": "string",
|
||||
"log": {
|
||||
"type": "plain",
|
||||
"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",
|
||||
@@ -357,3 +372,29 @@ class TestValidator(unittest.TestCase):
|
||||
c["tags"]["failover_priority"] = -6
|
||||
errors = schema(c)
|
||||
self.assertIn('tags.failover_priority -6 didn\'t pass validation: Wrong value', errors)
|
||||
|
||||
def test_json_log_format(self, *args):
|
||||
c = copy.deepcopy(config)
|
||||
c["log"]["type"] = "json"
|
||||
c["log"]["format"] = {"levelname": "level"}
|
||||
errors = schema(c)
|
||||
self.assertIn("log.format {'levelname': 'level'} didn't pass validation: Should be a string or a list", errors)
|
||||
|
||||
c["log"]["format"] = []
|
||||
errors = schema(c)
|
||||
self.assertIn("log.format [] didn't pass validation: should contain at least one item", errors)
|
||||
|
||||
c["log"]["format"] = [{"levelname": []}]
|
||||
errors = schema(c)
|
||||
self.assertIn("log.format [{'levelname': []}] didn't pass validation: "
|
||||
"each item should be a string or a dictionary with string values", errors)
|
||||
|
||||
c["log"]["format"] = [[]]
|
||||
errors = schema(c)
|
||||
self.assertIn("log.format [[]] didn't pass validation: "
|
||||
"each item should be a string or a dictionary with string values", errors)
|
||||
|
||||
c["log"]["format"] = ['foo']
|
||||
errors = schema(c)
|
||||
output = "\n".join(errors)
|
||||
self.assertEqual(['postgresql.bin_dir', 'raft.bind_addr', 'raft.self_addr'], parse_output(output))
|
||||
|
||||
+31
-12
@@ -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})
|
||||
@@ -164,30 +166,47 @@ class TestZooKeeper(unittest.TestCase):
|
||||
|
||||
def test__cluster_loader(self):
|
||||
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
||||
self.zk._cluster_loader(self.zk.client_path(''))
|
||||
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
|
||||
self.zk._base_path = self.zk._base_path = '/broken'
|
||||
self.zk._cluster_loader(self.zk.client_path(''))
|
||||
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
|
||||
self.zk._base_path = self.zk._base_path = '/legacy'
|
||||
self.zk._cluster_loader(self.zk.client_path(''))
|
||||
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
|
||||
self.zk._base_path = self.zk._base_path = '/no_node'
|
||||
self.zk._cluster_loader(self.zk.client_path(''))
|
||||
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
|
||||
|
||||
def test_get_cluster(self):
|
||||
cluster = self.zk.get_cluster()
|
||||
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)
|
||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||
|
||||
@patch('patroni.dcs.zookeeper.logger.error')
|
||||
@patch.object(ZooKeeper, '_cluster_loader', Mock(side_effect=Exception))
|
||||
@patch('patroni.dcs.logger.error')
|
||||
def test_get_mpp_coordinator(self, mock_logger):
|
||||
self.assertIsInstance(self.zk.get_mpp_coordinator(), Cluster)
|
||||
with patch.object(ZooKeeper, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
|
||||
self.assertIsNone(self.zk.get_mpp_coordinator())
|
||||
mock_logger.assert_called_once()
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from %s: %r')
|
||||
self.assertEqual(mock_logger.call_args[0][1], 'Null')
|
||||
self.assertEqual(mock_logger.call_args[0][2], 'ZooKeeper')
|
||||
self.assertIsInstance(mock_logger.call_args[0][3], ZooKeeperError)
|
||||
|
||||
@patch('patroni.dcs.logger.error')
|
||||
def test_get_citus_coordinator(self, mock_logger):
|
||||
self.assertIsNone(self.zk.get_citus_coordinator())
|
||||
mock_logger.assert_called_once()
|
||||
self.zk._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
||||
self.assertIsInstance(self.zk.get_mpp_coordinator(), Cluster)
|
||||
with patch.object(ZooKeeper, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
|
||||
self.assertIsNone(self.zk.get_mpp_coordinator())
|
||||
mock_logger.assert_called_once()
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from %s: %r')
|
||||
self.assertEqual(mock_logger.call_args[0][1], 'Citus')
|
||||
self.assertEqual(mock_logger.call_args[0][2], 'ZooKeeper')
|
||||
self.assertIsInstance(mock_logger.call_args[0][3], ZooKeeperError)
|
||||
|
||||
def test_delete_leader(self):
|
||||
self.assertTrue(self.zk.delete_leader(self.zk.get_cluster().leader))
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple
|
||||
class ConsulException(Exception): ...
|
||||
class NotFound(ConsulException): ...
|
||||
class CB:
|
||||
@classmethod
|
||||
def bool(klass) -> Callable[[NamedTuple], bool]: ...
|
||||
class Check:
|
||||
@classmethod
|
||||
def http(klass, url: str, interval: str, timeout: Optional[str] = None, deregister: Optional[str] = None) -> Dict[str, str]: ...
|
||||
class Consul:
|
||||
token: Optional[str]
|
||||
http: Any
|
||||
agent: 'Consul.Agent'
|
||||
session: 'Consul.Session'
|
||||
@@ -17,7 +21,9 @@ class Consul:
|
||||
service: 'Consul.Agent.Service'
|
||||
def self(self) -> Dict[str, Dict[str, Any]]: ...
|
||||
class Service:
|
||||
def register(self, name: str, service_id=..., address=..., port=..., tags=..., check=..., token=..., script=..., interval=..., ttl=..., http=..., timeout=..., enable_tag_override=...) -> bool: ...
|
||||
agent: 'Consul'
|
||||
def __init__(self, agent: 'Consul') -> None: ..
|
||||
def register(self, name: str, service_id: Optional[str] = None, address: Optional[str] = None, port: Optional[int] = None, tags: Optional[List[str]] = None, check: Optional[Dict[str, str]] = None, token: Optional[str] = None, enable_tag_override: bool = False) -> bool: ...
|
||||
def deregister(self, service_id: str) -> bool: ...
|
||||
class Session:
|
||||
def create(self, name: Optional[str] = None, node: Optional[str] = [], checks: Optional[List[str]]=None, lock_delay: float = 15, behavior: str = 'release', ttl: Optional[int] = None, dc: Optional[str] = None) -> str: ...
|
||||
|
||||
Reference in New Issue
Block a user