mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-29 08:59:32 +00:00
Compare commits
27
Commits
@@ -110,7 +110,7 @@ def install_etcd():
|
|||||||
|
|
||||||
|
|
||||||
def install_postgres():
|
def install_postgres():
|
||||||
version = os.environ.get('PGVERSION', '16.1-1')
|
version = os.environ.get('PGVERSION', '15.1-1')
|
||||||
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
|
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
|
||||||
if platform == 'osx':
|
if platform == 'osx':
|
||||||
return subprocess.call(['brew', 'install', 'expect', 'postgresql@{0}'.format(version.split('.')[0])])
|
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': '14', 'kubernetes': '15'}
|
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def main():
|
|||||||
unbuffer = ['timeout', '900', 'unbuffer']
|
unbuffer = ['timeout', '900', 'unbuffer']
|
||||||
else:
|
else:
|
||||||
if sys.platform == 'darwin':
|
if sys.platform == 'darwin':
|
||||||
version = os.environ.get('PGVERSION', '16.1-1')
|
version = os.environ.get('PGVERSION', '15.1-1')
|
||||||
path = '/usr/local/opt/postgresql@{0}/bin:.'.format(version.split('.')[0])
|
path = '/usr/local/opt/postgresql@{0}/bin:.'.format(version.split('.')[0])
|
||||||
unbuffer = ['unbuffer']
|
unbuffer = ['unbuffer']
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DCS: ${{ matrix.dcs }}
|
DCS: ${{ matrix.dcs }}
|
||||||
ETCDVERSION: 3.4.23
|
ETCDVERSION: 3.4.23
|
||||||
PGVERSION: 16.1-1 # for windows and macos
|
PGVERSION: 15.1-1 # for windows and macos
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
+1
-2
@@ -27,7 +27,7 @@ lib64
|
|||||||
pip-log.txt
|
pip-log.txt
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Unit test / coverage reports
|
||||||
.coverage*
|
.coverage
|
||||||
.tox
|
.tox
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
coverage.xml
|
coverage.xml
|
||||||
@@ -35,7 +35,6 @@ htmlcov
|
|||||||
junit.xml
|
junit.xml
|
||||||
features/output*
|
features/output*
|
||||||
dummy
|
dummy
|
||||||
result.json
|
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
|||||||
+4
-7
@@ -1,6 +1,6 @@
|
|||||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
## 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
|
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||||
ARG PG_MAJOR=16
|
ARG PG_MAJOR=15
|
||||||
ARG COMPRESS=false
|
ARG COMPRESS=false
|
||||||
ARG PGHOME=/home/postgres
|
ARG PGHOME=/home/postgres
|
||||||
ARG PGDATA=$PGHOME/data
|
ARG PGDATA=$PGHOME/data
|
||||||
@@ -94,9 +94,9 @@ RUN set -ex \
|
|||||||
/usr/share/locale/??_?? \
|
/usr/share/locale/??_?? \
|
||||||
/usr/share/postgresql/*/man \
|
/usr/share/postgresql/*/man \
|
||||||
/usr/share/postgresql-common/pg_wrapper \
|
/usr/share/postgresql-common/pg_wrapper \
|
||||||
/usr/share/vim/vim*/doc \
|
/usr/share/vim/vim80/doc \
|
||||||
/usr/share/vim/vim*/lang \
|
/usr/share/vim/vim80/lang \
|
||||||
/usr/share/vim/vim*/tutor \
|
/usr/share/vim/vim80/tutor \
|
||||||
# /var/lib/dpkg/info/* \
|
# /var/lib/dpkg/info/* \
|
||||||
&& find /usr/bin -xtype l -delete \
|
&& find /usr/bin -xtype l -delete \
|
||||||
&& find /var/log -type f -exec truncate --size 0 {} \; \
|
&& find /var/log -type f -exec truncate --size 0 {} \; \
|
||||||
@@ -125,8 +125,6 @@ RUN if [ "$COMPRESS" = "true" ]; then \
|
|||||||
&& /bin/busybox sh -c "(find $save_dirs -not -type d && cat /exclude /exclude && echo exclude) | sort | uniq -u | xargs /bin/busybox rm" \
|
&& /bin/busybox sh -c "(find $save_dirs -not -type d && cat /exclude /exclude && echo exclude) | sort | uniq -u | xargs /bin/busybox rm" \
|
||||||
&& /bin/busybox --install -s \
|
&& /bin/busybox --install -s \
|
||||||
&& /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \
|
&& /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \
|
||||||
else \
|
|
||||||
/bin/busybox --install -s; \
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
@@ -145,7 +143,6 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
|
|||||||
|
|
||||||
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
|
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
|
||||||
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
|
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
|
||||||
ENV ETCDCTL_API=3
|
|
||||||
|
|
||||||
COPY patroni /patroni/
|
COPY patroni /patroni/
|
||||||
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
|
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
|
||||||
|
|||||||
+5
-6
@@ -1,6 +1,6 @@
|
|||||||
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
|
## 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
|
## It has all the necessary components to play/debug with a single node appliance, running etcd
|
||||||
ARG PG_MAJOR=16
|
ARG PG_MAJOR=15
|
||||||
ARG COMPRESS=false
|
ARG COMPRESS=false
|
||||||
ARG PGHOME=/home/postgres
|
ARG PGHOME=/home/postgres
|
||||||
ARG PGDATA=$PGHOME/data
|
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 \
|
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 \
|
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||||
&& apt-get update -y \
|
&& apt-get update -y \
|
||||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-12.1; \
|
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
|
||||||
fi \
|
fi \
|
||||||
\
|
\
|
||||||
# Cleanup all locales but en_US.UTF-8
|
# Cleanup all locales but en_US.UTF-8
|
||||||
@@ -113,9 +113,9 @@ RUN set -ex \
|
|||||||
/usr/share/locale/??_?? \
|
/usr/share/locale/??_?? \
|
||||||
/usr/share/postgresql/*/man \
|
/usr/share/postgresql/*/man \
|
||||||
/usr/share/postgresql-common/pg_wrapper \
|
/usr/share/postgresql-common/pg_wrapper \
|
||||||
/usr/share/vim/vim*/doc \
|
/usr/share/vim/vim80/doc \
|
||||||
/usr/share/vim/vim*/lang \
|
/usr/share/vim/vim80/lang \
|
||||||
/usr/share/vim/vim*/tutor \
|
/usr/share/vim/vim80/tutor \
|
||||||
# /var/lib/dpkg/info/* \
|
# /var/lib/dpkg/info/* \
|
||||||
&& find /usr/bin -xtype l -delete \
|
&& find /usr/bin -xtype l -delete \
|
||||||
&& find /var/log -type f -exec truncate --size 0 {} \; \
|
&& find /var/log -type f -exec truncate --size 0 {} \; \
|
||||||
@@ -164,7 +164,6 @@ ARG PGBIN=/usr/lib/postgresql/$PG_MAJOR/bin
|
|||||||
|
|
||||||
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
|
ENV LC_ALL=$LC_ALL LANG=$LANG EDITOR=/usr/bin/editor
|
||||||
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
|
ENV PGDATA=$PGDATA PATH=$PATH:$PGBIN
|
||||||
ENV ETCDCTL_API=3
|
|
||||||
|
|
||||||
COPY patroni /patroni/
|
COPY patroni /patroni/
|
||||||
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
|
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
|
||||||
|
|||||||
+1
-1
@@ -151,7 +151,7 @@ run:
|
|||||||
YAML Configuration
|
YAML Configuration
|
||||||
==================
|
==================
|
||||||
|
|
||||||
Go `here <https://github.com/zalando/patroni/blob/master/docs/dynamic_configuration.rst>`__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
|
Go `here <https://github.com/zalando/patroni/blob/master/docs/SETTINGS.rst>`__ for comprehensive information about settings for etcd, consul, and ZooKeeper. And for an example, see `postgres0.yml <https://github.com/zalando/patroni/blob/master/postgres0.yml>`__.
|
||||||
|
|
||||||
=========================
|
=========================
|
||||||
Environment Configuration
|
Environment Configuration
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ services:
|
|||||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||||
networks: [ demo ]
|
networks: [ demo ]
|
||||||
environment:
|
environment:
|
||||||
|
ETCDCTL_API: 3
|
||||||
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
|
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
|
||||||
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
|
ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379
|
||||||
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
|
ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
|
||||||
@@ -27,19 +28,19 @@ services:
|
|||||||
ETCD_UNSUPPORTED_ARCH: arm64
|
ETCD_UNSUPPORTED_ARCH: arm64
|
||||||
container_name: demo-etcd1
|
container_name: demo-etcd1
|
||||||
hostname: etcd1
|
hostname: etcd1
|
||||||
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
|
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
|
||||||
|
|
||||||
etcd2:
|
etcd2:
|
||||||
<<: *etcd
|
<<: *etcd
|
||||||
container_name: demo-etcd2
|
container_name: demo-etcd2
|
||||||
hostname: etcd2
|
hostname: etcd2
|
||||||
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
|
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
|
||||||
|
|
||||||
etcd3:
|
etcd3:
|
||||||
<<: *etcd
|
<<: *etcd
|
||||||
container_name: demo-etcd3
|
container_name: demo-etcd3
|
||||||
hostname: etcd3
|
hostname: etcd3
|
||||||
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
|
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
|
||||||
|
|
||||||
haproxy:
|
haproxy:
|
||||||
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
|
||||||
@@ -52,6 +53,7 @@ services:
|
|||||||
- "5001:5001" # Load-balancing across workers primaries
|
- "5001:5001" # Load-balancing across workers primaries
|
||||||
command: haproxy
|
command: haproxy
|
||||||
environment: &haproxy_env
|
environment: &haproxy_env
|
||||||
|
ETCDCTL_API: 3
|
||||||
ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
|
ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
|
||||||
PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'"
|
PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'"
|
||||||
PATRONI_SCOPE: demo
|
PATRONI_SCOPE: demo
|
||||||
|
|||||||
+3
-3
@@ -25,19 +25,19 @@ services:
|
|||||||
ETCD_UNSUPPORTED_ARCH: arm64
|
ETCD_UNSUPPORTED_ARCH: arm64
|
||||||
container_name: demo-etcd1
|
container_name: demo-etcd1
|
||||||
hostname: etcd1
|
hostname: etcd1
|
||||||
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
|
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
|
||||||
|
|
||||||
etcd2:
|
etcd2:
|
||||||
<<: *etcd
|
<<: *etcd
|
||||||
container_name: demo-etcd2
|
container_name: demo-etcd2
|
||||||
hostname: etcd2
|
hostname: etcd2
|
||||||
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
|
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
|
||||||
|
|
||||||
etcd3:
|
etcd3:
|
||||||
<<: *etcd
|
<<: *etcd
|
||||||
container_name: demo-etcd3
|
container_name: demo-etcd3
|
||||||
hostname: etcd3
|
hostname: etcd3
|
||||||
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
|
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
|
||||||
|
|
||||||
haproxy:
|
haproxy:
|
||||||
image: ${PATRONI_TEST_IMAGE:-patroni}
|
image: ${PATRONI_TEST_IMAGE:-patroni}
|
||||||
|
|||||||
+160
-160
@@ -19,97 +19,102 @@ The haproxy listens on ports 5000 (connects to the primary) and 5001 (does load-
|
|||||||
|
|
||||||
Example session:
|
Example session:
|
||||||
|
|
||||||
$ docker compose up -d
|
$ docker-compose up -d
|
||||||
✔ Network patroni_demo Created
|
Creating demo-haproxy ...
|
||||||
✔ Container demo-etcd1 Started
|
Creating demo-patroni2 ...
|
||||||
✔ Container demo-haproxy Started
|
Creating demo-patroni1 ...
|
||||||
✔ Container demo-patroni1 Started
|
Creating demo-patroni3 ...
|
||||||
✔ Container demo-patroni2 Started
|
Creating demo-etcd2 ...
|
||||||
✔ Container demo-patroni3 Started
|
Creating demo-etcd1 ...
|
||||||
✔ Container demo-etcd2 Started
|
Creating demo-etcd3 ...
|
||||||
✔ Container demo-etcd3 Started
|
Creating demo-haproxy
|
||||||
|
Creating demo-patroni2
|
||||||
|
Creating demo-patroni1
|
||||||
|
Creating demo-patroni3
|
||||||
|
Creating demo-etcd1
|
||||||
|
Creating demo-etcd2
|
||||||
|
Creating demo-etcd2 ... done
|
||||||
|
|
||||||
$ docker ps
|
$ docker ps
|
||||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||||
a37bcec56726 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd3
|
5b7a90b4cfbf patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd2
|
||||||
034ab73868a8 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni2
|
e30eea5222f2 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd1
|
||||||
03837736f710 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni3
|
83bcf3cb208f patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd3
|
||||||
22815c3d85b3 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd2
|
922532c56e7d patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni3
|
||||||
814b4304d132 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes 0.0.0.0:5000-5001->5000-5001/tcp, :::5000-5001->5000-5001/tcp demo-haproxy
|
14f875e445f3 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni2
|
||||||
6375b0ba2d0a patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni1
|
110d1073b383 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni1
|
||||||
aef8bf3ee91f patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd1
|
5af5e6e36028 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy
|
||||||
|
|
||||||
$ docker logs demo-patroni1
|
$ docker logs demo-patroni1
|
||||||
2023-11-21 09:04:33,547 INFO: Selected new etcd server http://172.29.0.3:2379
|
2019-02-20 08:19:32,714 INFO: Failed to import patroni.dcs.consul
|
||||||
2023-11-21 09:04:33,605 INFO: Lock owner: None; I am patroni1
|
2019-02-20 08:19:32,737 INFO: Selected new etcd server http://etcd3:2379
|
||||||
2023-11-21 09:04:33,693 INFO: trying to bootstrap a new cluster
|
2019-02-20 08:19:35,140 INFO: Lock owner: None; I am patroni1
|
||||||
|
2019-02-20 08:19:35,174 INFO: trying to bootstrap a new cluster
|
||||||
...
|
...
|
||||||
2023-11-21 09:04:34.920 UTC [43] LOG: starting PostgreSQL 15.5 (Debian 15.5-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
|
2019-02-20 08:19:39,310 INFO: postmaster pid=37
|
||||||
2023-11-21 09:04:34.921 UTC [43] LOG: listening on IPv4 address "0.0.0.0", port 5432
|
2019-02-20 08:19:39.314 UTC [37] LOG: listening on IPv4 address "0.0.0.0", port 5432
|
||||||
2023-11-21 09:04:34,922 INFO: postmaster pid=43
|
2019-02-20 08:19:39.321 UTC [37] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
|
||||||
2023-11-21 09:04:34.922 UTC [43] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
|
2019-02-20 08:19:39.353 UTC [39] LOG: database system was shut down at 2019-02-20 08:19:36 UTC
|
||||||
2023-11-21 09:04:34.925 UTC [47] LOG: database system was shut down at 2023-11-21 09:04:34 UTC
|
2019-02-20 08:19:39.354 UTC [40] FATAL: the database system is starting up
|
||||||
2023-11-21 09:04:34.928 UTC [43] LOG: database system is ready to accept connections
|
localhost:5432 - rejecting connections
|
||||||
|
2019-02-20 08:19:39.369 UTC [37] LOG: database system is ready to accept connections
|
||||||
localhost:5432 - accepting connections
|
localhost:5432 - accepting connections
|
||||||
localhost:5432 - accepting connections
|
2019-02-20 08:19:39,383 INFO: establishing a new patroni connection to the postgres cluster
|
||||||
2023-11-21 09:04:34,938 INFO: establishing a new patroni heartbeat connection to postgres
|
2019-02-20 08:19:39,408 INFO: running post_bootstrap
|
||||||
2023-11-21 09:04:34,992 INFO: running post_bootstrap
|
2019-02-20 08:19:39,432 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'"
|
||||||
2023-11-21 09:04:35,004 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0
|
2019-02-20 08:19:39,515 INFO: initialized a new cluster
|
||||||
2023-11-21 09:04:35,009 WARNING: Could not activate Linux watchdog device: Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'
|
2019-02-20 08:19:49,424 INFO: Lock owner: patroni1; I am patroni1
|
||||||
2023-11-21 09:04:35,189 INFO: initialized a new cluster
|
2019-02-20 08:19:49,447 INFO: Lock owner: patroni1; I am patroni1
|
||||||
2023-11-21 09:04:35,328 INFO: no action. I am (patroni1), the leader with the lock
|
2019-02-20 08:19:49,480 INFO: no action. i am the leader with the lock
|
||||||
2023-11-21 09:04:43,824 INFO: establishing a new patroni restapi connection to postgres
|
2019-02-20 08:19:59,422 INFO: Lock owner: patroni1; I am patroni1
|
||||||
2023-11-21 09:04:45,322 INFO: no action. I am (patroni1), the leader with the lock
|
|
||||||
2023-11-21 09:04:55,320 INFO: no action. I am (patroni1), the leader with the lock
|
|
||||||
...
|
|
||||||
|
|
||||||
$ docker exec -ti demo-patroni1 bash
|
$ docker exec -ti demo-patroni1 bash
|
||||||
postgres@patroni1:~$ patronictl list
|
postgres@patroni1:~$ patronictl list
|
||||||
+ Cluster: demo (7303838734793224214) --------+----+-----------+
|
+---------+----------+------------+--------+---------+----+-----------+
|
||||||
| Member | Host | Role | State | TL | Lag in MB |
|
| Cluster | Member | Host | Role | State | TL | Lag in MB |
|
||||||
+----------+------------+---------+-----------+----+-----------+
|
+---------+----------+------------+--------+---------+----+-----------+
|
||||||
| patroni1 | 172.29.0.2 | Leader | running | 1 | |
|
| demo | patroni1 | 172.22.0.3 | Leader | running | 1 | 0 |
|
||||||
| patroni2 | 172.29.0.6 | Replica | streaming | 1 | 0 |
|
| demo | patroni2 | 172.22.0.7 | | running | 1 | 0 |
|
||||||
| patroni3 | 172.29.0.5 | Replica | streaming | 1 | 0 |
|
| demo | patroni3 | 172.22.0.4 | | running | 1 | 0 |
|
||||||
+----------+------------+---------+-----------+----+-----------+
|
+---------+----------+------------+--------+---------+----+-----------+
|
||||||
|
|
||||||
postgres@patroni1:~$ etcdctl get --keys-only --prefix /service/demo
|
postgres@patroni1:~$ etcdctl get --keys-only --prefix /service/demo
|
||||||
/service/demo/config
|
/service/demo/config
|
||||||
/service/demo/initialize
|
/service/demo/initialize
|
||||||
/service/demo/leader
|
/service/demo/leader
|
||||||
|
/service/demo/members/
|
||||||
/service/demo/members/patroni1
|
/service/demo/members/patroni1
|
||||||
/service/demo/members/patroni2
|
/service/demo/members/patroni2
|
||||||
/service/demo/members/patroni3
|
/service/demo/members/patroni3
|
||||||
/service/demo/status
|
/service/demo/optime/
|
||||||
|
/service/demo/optime/leader
|
||||||
|
|
||||||
postgres@patroni1:~$ etcdctl member list
|
postgres@patroni1:~$ etcdctl member list
|
||||||
2bf3e2ceda5d5960, started, etcd2, http://etcd2:2380, http://172.29.0.3:2379
|
1bab629f01fa9065: name=etcd3 peerURLs=http://etcd3:2380 clientURLs=http://etcd3:2379 isLeader=false
|
||||||
55b3264e129c7005, started, etcd3, http://etcd3:2380, http://172.29.0.7:2379
|
8ecb6af518d241cc: name=etcd2 peerURLs=http://etcd2:2380 clientURLs=http://etcd2:2379 isLeader=true
|
||||||
acce7233f8ec127e, started, etcd1, http://etcd1:2380, http://172.29.0.8:2379
|
b2e169fcb8a34028: name=etcd1 peerURLs=http://etcd1:2380 clientURLs=http://etcd1:2379 isLeader=false
|
||||||
|
|
||||||
|
|
||||||
postgres@patroni1:~$ exit
|
postgres@patroni1:~$ exit
|
||||||
|
|
||||||
$ docker exec -ti demo-haproxy bash
|
$ docker exec -ti demo-haproxy bash
|
||||||
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -W
|
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -W
|
||||||
Password: postgres
|
Password: postgres
|
||||||
psql (15.5 (Debian 15.5-1.pgdg120+1))
|
psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1))
|
||||||
Type "help" for help.
|
Type "help" for help.
|
||||||
|
|
||||||
postgres=# SELECT pg_is_in_recovery();
|
localhost/postgres=# select pg_is_in_recovery();
|
||||||
pg_is_in_recovery
|
pg_is_in_recovery
|
||||||
───────────────────
|
───────────────────
|
||||||
f
|
f
|
||||||
(1 row)
|
(1 row)
|
||||||
|
|
||||||
postgres=# \q
|
localhost/postgres=# \q
|
||||||
|
|
||||||
postgres@haproxy:~$ psql -h localhost -p 5001 -U postgres -W
|
$postgres@haproxy:~ psql -h localhost -p 5001 -U postgres -W
|
||||||
Password: postgres
|
Password: postgres
|
||||||
psql (15.5 (Debian 15.5-1.pgdg120+1))
|
psql (11.2 (Ubuntu 11.2-1.pgdg18.04+1), server 10.7 (Debian 10.7-1.pgdg90+1))
|
||||||
Type "help" for help.
|
Type "help" for help.
|
||||||
|
|
||||||
postgres=# SELECT pg_is_in_recovery();
|
localhost/postgres=# select pg_is_in_recovery();
|
||||||
pg_is_in_recovery
|
pg_is_in_recovery
|
||||||
───────────────────
|
───────────────────
|
||||||
t
|
t
|
||||||
@@ -122,86 +127,81 @@ The haproxy listens on ports 5000 (connects to the coordinator primary) and 5001
|
|||||||
|
|
||||||
Example session:
|
Example session:
|
||||||
|
|
||||||
$ docker compose -f docker-compose-citus.yml up -d
|
$ docker-compose -f docker-compose-citus.yml up -d
|
||||||
✔ Network patroni_demo Created
|
Creating demo-work2-1 ... done
|
||||||
✔ Container demo-coord2 Started
|
Creating demo-work1-1 ... done
|
||||||
✔ Container demo-work2-2 Started
|
Creating demo-etcd2 ... done
|
||||||
✔ Container demo-etcd1 Started
|
Creating demo-etcd1 ... done
|
||||||
✔ Container demo-haproxy Started
|
Creating demo-coord3 ... done
|
||||||
✔ Container demo-work1-1 Started
|
Creating demo-etcd3 ... done
|
||||||
✔ Container demo-work2-1 Started
|
Creating demo-coord1 ... done
|
||||||
✔ Container demo-work1-2 Started
|
Creating demo-haproxy ... done
|
||||||
✔ Container demo-coord1 Started
|
Creating demo-work2-2 ... done
|
||||||
✔ Container demo-etcd3 Started
|
Creating demo-coord2 ... done
|
||||||
✔ Container demo-coord3 Started
|
Creating demo-work1-2 ... done
|
||||||
✔ Container demo-etcd2 Started
|
|
||||||
|
|
||||||
|
|
||||||
$ docker ps
|
$ docker ps
|
||||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||||
79c95492fac9 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd3
|
852d8885a612 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-coord3
|
||||||
77eb82d0f0c1 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-1
|
cdd692f947ab patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work1-2
|
||||||
03dacd7267ef patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd1
|
9f4e340b36da patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-etcd3
|
||||||
db9206c66f85 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd2
|
d69c129a960a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd1
|
||||||
9a0fef7b7dd4 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-2
|
c5849689b8cd patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord1
|
||||||
f06b031d99dc patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-2
|
c9d72bd6217d patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-1
|
||||||
f7c58545f314 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord2
|
24b1b43efa05 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord2
|
||||||
383f9e7e188a patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-1
|
cb0cc2b4ca0a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-2
|
||||||
f02e96dcc9d6 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord3
|
9796c6b8aad5 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 5 seconds demo-work1-1
|
||||||
6945834b7056 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord1
|
8baccd74dcae patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd2
|
||||||
b96ca42f785d patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes 0.0.0.0:5000-5001->5000-5001/tcp, :::5000-5001->5000-5001/tcp demo-haproxy
|
353ec62a0187 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds 0.0.0.0:5000-5001->5000-5001/tcp demo-haproxy
|
||||||
|
|
||||||
|
|
||||||
$ docker logs demo-coord1
|
$ docker logs demo-coord1
|
||||||
2023-11-21 09:36:14,293 INFO: Selected new etcd server http://172.30.0.4:2379
|
2023-01-05 15:09:31,295 INFO: Selected new etcd server http://172.27.0.4:2379
|
||||||
2023-11-21 09:36:14,390 INFO: Lock owner: None; I am coord1
|
2023-01-05 15:09:31,388 INFO: Lock owner: None; I am coord1
|
||||||
2023-11-21 09:36:14,478 INFO: trying to bootstrap a new cluster
|
2023-01-05 15:09:31,501 INFO: trying to bootstrap a new cluster
|
||||||
...
|
...
|
||||||
2023-11-21 09:36:16,475 INFO: postmaster pid=52
|
2023-01-05 15:09:45,096 INFO: postmaster pid=39
|
||||||
localhost:5432 - no response
|
localhost:5432 - no response
|
||||||
2023-11-21 09:36:16.495 UTC [52] LOG: starting PostgreSQL 15.5 (Debian 15.5-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
|
2023-01-05 15:09:45.137 UTC [39] LOG: starting PostgreSQL 15.1 (Debian 15.1-1.pgdg110+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 10.2.1-6) 10.2.1 20210110, 64-bit
|
||||||
2023-11-21 09:36:16.495 UTC [52] LOG: listening on IPv4 address "0.0.0.0", port 5432
|
2023-01-05 15:09:45.137 UTC [39] LOG: listening on IPv4 address "0.0.0.0", port 5432
|
||||||
2023-11-21 09:36:16.496 UTC [52] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
|
2023-01-05 15:09:45.152 UTC [39] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
|
||||||
2023-11-21 09:36:16.498 UTC [56] LOG: database system was shut down at 2023-11-21 09:36:15 UTC
|
2023-01-05 15:09:45.177 UTC [43] LOG: database system was shut down at 2023-01-05 15:09:32 UTC
|
||||||
2023-11-21 09:36:16.501 UTC [52] LOG: database system is ready to accept connections
|
2023-01-05 15:09:45.193 UTC [39] LOG: database system is ready to accept connections
|
||||||
localhost:5432 - accepting connections
|
localhost:5432 - accepting connections
|
||||||
localhost:5432 - accepting connections
|
localhost:5432 - accepting connections
|
||||||
2023-11-21 09:36:17,509 INFO: establishing a new patroni heartbeat connection to postgres
|
2023-01-05 15:09:46,139 INFO: establishing a new patroni connection to the postgres cluster
|
||||||
2023-11-21 09:36:17,569 INFO: running post_bootstrap
|
2023-01-05 15:09:46,208 INFO: running post_bootstrap
|
||||||
2023-11-21 09:36:17,593 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0
|
2023-01-05 15:09:47.209 UTC [55] LOG: starting maintenance daemon on database 16386 user 10
|
||||||
2023-11-21 09:36:17,783 INFO: establishing a new patroni restapi connection to postgres
|
2023-01-05 15:09:47.209 UTC [55] CONTEXT: Citus maintenance daemon for database 16386 user 10
|
||||||
2023-11-21 09:36:17,969 WARNING: Could not activate Linux watchdog device: Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'
|
2023-01-05 15:09:47,215 WARNING: Could not activate Linux watchdog device: "Can't open watchdog device: [Errno 2] No such file or directory: '/dev/watchdog'"
|
||||||
2023-11-21 09:36:17.969 UTC [70] LOG: starting maintenance daemon on database 16386 user 10
|
2023-01-05 15:09:47.446 UTC [41] LOG: checkpoint starting: immediate force wait
|
||||||
2023-11-21 09:36:17.969 UTC [70] CONTEXT: Citus maintenance daemon for database 16386 user 10
|
2023-01-05 15:09:47,466 INFO: initialized a new cluster
|
||||||
2023-11-21 09:36:18.159 UTC [54] LOG: checkpoint starting: immediate force wait
|
2023-01-05 15:09:47,594 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ())
|
||||||
2023-11-21 09:36:18,162 INFO: initialized a new cluster
|
2023-01-05 15:09:47,594 INFO: establishing a new patroni connection to the postgres cluster
|
||||||
2023-11-21 09:36:18,164 INFO: Lock owner: coord1; I am coord1
|
2023-01-05 15:09:47,467 INFO: Lock owner: coord1; I am coord1
|
||||||
2023-11-21 09:36:18,297 INFO: Enabled synchronous replication
|
2023-01-05 15:09:47,613 DEBUG: query(SELECT pg_catalog.citus_set_coordinator_host(%s, %s, 'primary', 'default'), ('172.27.0.6', 5432))
|
||||||
2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=0,host=172.30.0.3,port=5432,event=after_promote)
|
2023-01-05 15:09:47,924 INFO: no action. I am (coord1), the leader with the lock
|
||||||
2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=1,host=172.30.0.7,port=5432,event=after_promote)
|
2023-01-05 15:09:51.282 UTC [41] LOG: checkpoint complete: wrote 1086 buffers (53.0%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.029 s, sync=3.746 s, total=3.837 s; sync files=280, longest=0.028 s, average=0.014 s; distance=8965 kB, estimate=8965 kB
|
||||||
2023-11-21 09:36:18,298 DEBUG: Adding the new task: PgDistNode(nodeid=None,group=2,host=172.30.0.8,port=5432,event=after_promote)
|
2023-01-05 15:09:51.283 UTC [41] LOG: checkpoint starting: immediate force wait
|
||||||
2023-11-21 09:36:18,299 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ())
|
2023-01-05 15:09:51.495 UTC [41] LOG: checkpoint complete: wrote 18 buffers (0.9%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.044 s, sync=0.091 s, total=0.212 s; sync files=15, longest=0.015 s, average=0.007 s; distance=67 kB, estimate=8076 kB
|
||||||
2023-11-21 09:36:18,299 INFO: establishing a new patroni citus connection to postgres
|
2023-01-05 15:09:57,467 INFO: Lock owner: coord1; I am coord1
|
||||||
2023-11-21 09:36:18,323 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.30.0.7', 5432, 1))
|
2023-01-05 15:09:57,569 INFO: Assigning synchronous standby status to ['coord3']
|
||||||
2023-11-21 09:36:18,361 INFO: no action. I am (coord1), the leader with the lock
|
|
||||||
2023-11-21 09:36:18,393 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.30.0.8', 5432, 2))
|
|
||||||
2023-11-21 09:36:28,164 INFO: Lock owner: coord1; I am coord1
|
|
||||||
2023-11-21 09:36:28,251 INFO: Assigning synchronous standby status to ['coord3']
|
|
||||||
server signaled
|
server signaled
|
||||||
2023-11-21 09:36:28.435 UTC [52] LOG: received SIGHUP, reloading configuration files
|
2023-01-05 15:09:57.574 UTC [39] LOG: received SIGHUP, reloading configuration files
|
||||||
2023-11-21 09:36:28.436 UTC [52] LOG: parameter "synchronous_standby_names" changed to "coord3"
|
2023-01-05 15:09:57.580 UTC [39] LOG: parameter "synchronous_standby_names" changed to "coord3"
|
||||||
2023-11-21 09:36:28.641 UTC [83] LOG: standby "coord3" is now a synchronous standby with priority 1
|
2023-01-05 15:09:59,637 INFO: Synchronous standby status assigned to ['coord3']
|
||||||
2023-11-21 09:36:28.641 UTC [83] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1
|
2023-01-05 15:09:59,638 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.27.0.2', 5432, 1))
|
||||||
2023-11-21 09:36:30,582 INFO: Synchronous standby status assigned to ['coord3']
|
2023-01-05 15:09:59.690 UTC [67] LOG: standby "coord3" is now a synchronous standby with priority 1
|
||||||
2023-11-21 09:36:30,626 INFO: no action. I am (coord1), the leader with the lock
|
2023-01-05 15:09:59.690 UTC [67] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1
|
||||||
2023-11-21 09:36:38,250 INFO: no action. I am (coord1), the leader with the lock
|
2023-01-05 15:09:59,694 INFO: no action. I am (coord1), the leader with the lock
|
||||||
...
|
2023-01-05 15:09:59,704 DEBUG: query(SELECT pg_catalog.citus_add_node(%s, %s, %s, 'primary', 'default'), ('172.27.0.8', 5432, 2))
|
||||||
|
2023-01-05 15:10:07,625 INFO: no action. I am (coord1), the leader with the lock
|
||||||
|
2023-01-05 15:10:17,579 INFO: no action. I am (coord1), the leader with the lock
|
||||||
|
|
||||||
$ docker exec -ti demo-haproxy bash
|
$ docker exec -ti demo-haproxy bash
|
||||||
postgres@haproxy:~$ etcdctl member list
|
postgres@haproxy:~$ etcdctl member list
|
||||||
2b28411e74c0c281, started, etcd3, http://etcd3:2380, http://172.30.0.4:2379
|
1bab629f01fa9065, started, etcd3, http://etcd3:2380, http://172.27.0.10:2379
|
||||||
6c70137d27cfa6c1, started, etcd2, http://etcd2:2380, http://172.30.0.5:2379
|
8ecb6af518d241cc, started, etcd2, http://etcd2:2380, http://172.27.0.4:2379
|
||||||
a28f9a70ebf21304, started, etcd1, http://etcd1:2380, http://172.30.0.6:2379
|
b2e169fcb8a34028, started, etcd1, http://etcd1:2380, http://172.27.0.7:2379
|
||||||
|
|
||||||
postgres@haproxy:~$ etcdctl get --keys-only --prefix /service/demo
|
postgres@haproxy:~$ etcdctl get --keys-only --prefix /service/demo
|
||||||
/service/demo/0/config
|
/service/demo/0/config
|
||||||
@@ -229,7 +229,7 @@ Example session:
|
|||||||
|
|
||||||
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
|
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
|
||||||
Password for user postgres: postgres
|
Password for user postgres: postgres
|
||||||
psql (15.5 (Debian 15.5-1.pgdg120+1))
|
psql (15.1 (Debian 15.1-1.pgdg110+1))
|
||||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||||
Type "help" for help.
|
Type "help" for help.
|
||||||
|
|
||||||
@@ -242,65 +242,65 @@ Example session:
|
|||||||
citus=# table pg_dist_node;
|
citus=# table pg_dist_node;
|
||||||
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
||||||
--------+---------+------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
|
--------+---------+------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
|
||||||
1 | 0 | 172.30.0.3 | 5432 | default | t | t | primary | default | t | f
|
1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f
|
||||||
2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t
|
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t
|
||||||
3 | 2 | 172.30.0.8 | 5432 | default | t | t | primary | default | t | t
|
3 | 2 | 172.27.0.8 | 5432 | default | t | t | primary | default | t | t
|
||||||
(3 rows)
|
(3 rows)
|
||||||
|
|
||||||
citus=# \q
|
citus=# \q
|
||||||
|
|
||||||
postgres@haproxy:~$ patronictl list
|
postgres@haproxy:~$ patronictl list
|
||||||
+ Citus cluster: demo ----------+--------------+-----------+----+-----------+
|
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||||
+-------+---------+-------------+--------------+-----------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
| 0 | coord1 | 172.30.0.3 | Leader | running | 1 | |
|
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | |
|
||||||
| 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 |
|
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 |
|
||||||
| 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 |
|
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 |
|
||||||
| 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | |
|
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | |
|
||||||
| 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 |
|
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 |
|
||||||
| 2 | work2-1 | 172.30.0.8 | Leader | running | 1 | |
|
| 2 | work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 |
|
||||||
| 2 | work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 |
|
| 2 | work2-2 | 172.27.0.8 | Leader | running | 1 | |
|
||||||
+-------+---------+-------------+--------------+-----------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
|
|
||||||
|
|
||||||
postgres@haproxy:~$ patronictl switchover --group 2 --force
|
postgres@haproxy:~$ patronictl switchover --group 2 --force
|
||||||
Current cluster topology
|
Current cluster topology
|
||||||
+ Citus cluster: demo (group: 2, 7303846899271086103) --+-----------+
|
+ Citus cluster: demo (group: 2, 7185185529556963355) +-----------+
|
||||||
| Member | Host | Role | State | TL | Lag in MB |
|
| Member | Host | Role | State | TL | Lag in MB |
|
||||||
+---------+-------------+--------------+-----------+----+-----------+
|
+---------+-------------+--------------+---------+----+-----------+
|
||||||
| work2-1 | 172.30.0.8 | Leader | running | 1 | |
|
| work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 |
|
||||||
| work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 |
|
| work2-2 | 172.27.0.8 | Leader | running | 1 | |
|
||||||
+---------+-------------+--------------+-----------+----+-----------+
|
+---------+-------------+--------------+---------+----+-----------+
|
||||||
2023-11-21 09:44:15.83849 Successfully switched over to "work2-2"
|
2023-01-05 15:29:29.54204 Successfully switched over to "work2-1"
|
||||||
+ Citus cluster: demo (group: 2, 7303846899271086103) -------+
|
+ Citus cluster: demo (group: 2, 7185185529556963355) -------+
|
||||||
| Member | Host | Role | State | TL | Lag in MB |
|
| Member | Host | Role | State | TL | Lag in MB |
|
||||||
+---------+-------------+---------+---------+----+-----------+
|
+---------+-------------+---------+---------+----+-----------+
|
||||||
| work2-1 | 172.30.0.8 | Replica | stopped | | unknown |
|
| work2-1 | 172.27.0.11 | Leader | running | 1 | |
|
||||||
| work2-2 | 172.30.0.11 | Leader | running | 1 | |
|
| work2-2 | 172.27.0.8 | Replica | stopped | | unknown |
|
||||||
+---------+-------------+---------+---------+----+-----------+
|
+---------+-------------+---------+---------+----+-----------+
|
||||||
|
|
||||||
postgres@haproxy:~$ patronictl list
|
postgres@haproxy:~$ patronictl list
|
||||||
+ Citus cluster: demo ----------+--------------+-----------+----+-----------+
|
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||||
+-------+---------+-------------+--------------+-----------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
| 0 | coord1 | 172.30.0.3 | Leader | running | 1 | |
|
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | |
|
||||||
| 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 |
|
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 |
|
||||||
| 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 |
|
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 |
|
||||||
| 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | |
|
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | |
|
||||||
| 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 |
|
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 |
|
||||||
| 2 | work2-1 | 172.30.0.8 | Sync Standby | streaming | 2 | 0 |
|
| 2 | work2-1 | 172.27.0.11 | Leader | running | 2 | |
|
||||||
| 2 | work2-2 | 172.30.0.11 | Leader | running | 2 | |
|
| 2 | work2-2 | 172.27.0.8 | Sync Standby | running | 2 | 0 |
|
||||||
+-------+---------+-------------+--------------+-----------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
|
|
||||||
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
|
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
|
||||||
psql (15.5 (Debian 15.5-1.pgdg120+1))
|
Password for user postgres: postgres
|
||||||
|
psql (15.1 (Debian 15.1-1.pgdg110+1))
|
||||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||||
Type "help" for help.
|
Type "help" for help.
|
||||||
|
|
||||||
citus=# table pg_dist_node;
|
citus=# table pg_dist_node;
|
||||||
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
nodeid | groupid | nodename | nodeport | noderack | hasmetadata | isactive | noderole | nodecluster | metadatasynced | shouldhaveshards
|
||||||
--------+---------+-------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
|
--------+---------+-------------+----------+----------+-------------+----------+----------+-------------+----------------+------------------
|
||||||
1 | 0 | 172.30.0.3 | 5432 | default | t | t | primary | default | t | f
|
1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f
|
||||||
3 | 2 | 172.30.0.11 | 5432 | default | t | t | primary | default | t | t
|
3 | 2 | 172.27.0.11 | 5432 | default | t | t | primary | default | t | t
|
||||||
2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t
|
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t
|
||||||
(3 rows)
|
(3 rows)
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ readonly PATRONI_NAMESPACE="${PATRONI_NAMESPACE%/}"
|
|||||||
DOCKER_IP=$(hostname --ip-address)
|
DOCKER_IP=$(hostname --ip-address)
|
||||||
readonly DOCKER_IP
|
readonly DOCKER_IP
|
||||||
|
|
||||||
export DUMB_INIT_SETSID=0
|
|
||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
haproxy)
|
haproxy)
|
||||||
haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D
|
haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -D
|
||||||
@@ -74,4 +72,4 @@ export PATRONI_SUPERUSER_SSLKEY="${PATRONI_SUPERUSER_SSLKEY:-$PGSSLKEY}"
|
|||||||
export PATRONI_SUPERUSER_SSLCERT="${PATRONI_SUPERUSER_SSLCERT:-$PGSSLCERT}"
|
export PATRONI_SUPERUSER_SSLCERT="${PATRONI_SUPERUSER_SSLCERT:-$PGSSLCERT}"
|
||||||
export PATRONI_SUPERUSER_SSLROOTCERT="${PATRONI_SUPERUSER_SSLROOTCERT:-$PGSSLROOTCERT}"
|
export PATRONI_SUPERUSER_SSLROOTCERT="${PATRONI_SUPERUSER_SSLROOTCERT:-$PGSSLROOTCERT}"
|
||||||
|
|
||||||
exec dumb-init python3 /patroni.py postgres0.yml
|
exec python3 /patroni.py postgres0.yml
|
||||||
|
|||||||
+1
-10
@@ -14,18 +14,10 @@ Global/Universal
|
|||||||
|
|
||||||
Log
|
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\_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\_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. If the log type is **plain**, the log format should be a string.
|
- **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>`_)
|
||||||
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\_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\_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\_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.
|
- **PATRONI\_LOG\_FILE\_NUM**: The number of application logs to retain.
|
||||||
@@ -93,7 +85,6 @@ ZooKeeper
|
|||||||
- **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password.
|
- **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password.
|
||||||
- **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
- **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
||||||
- **PATRONI\_ZOOKEEPER\_SET\_ACLS**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
- **PATRONI\_ZOOKEEPER\_SET\_ACLS**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
||||||
- **PATRONI\_ZOOKEEPER\_AUTH\_DATA**: (optional) Authentication credentials to use for the connection. Should be a dictionary in the form that `scheme` is the key and `credential` is the value. Defaults to empty dictionary.
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
||||||
|
|||||||
+30
-32
@@ -34,8 +34,6 @@ There are only a few simple rules you need to follow:
|
|||||||
|
|
||||||
After that you just need to start Patroni and it will handle the rest:
|
After that you just need to start Patroni and it will handle the rest:
|
||||||
|
|
||||||
0. Patroni will set ``bootstrap.dcs.synchronous_mode`` to :ref:`quorum <quorum_mode>`
|
|
||||||
if it is not explicitly set to any other value.
|
|
||||||
1. ``citus`` extension will be automatically added to ``shared_preload_libraries``.
|
1. ``citus`` extension will be automatically added to ``shared_preload_libraries``.
|
||||||
2. If ``max_prepared_transactions`` isn't explicitly set in the global
|
2. If ``max_prepared_transactions`` isn't explicitly set in the global
|
||||||
:ref:`dynamic configuration <dynamic_configuration>` Patroni will
|
:ref:`dynamic configuration <dynamic_configuration>` Patroni will
|
||||||
@@ -79,36 +77,36 @@ It results in two major differences in :ref:`patronictl` behaviour when
|
|||||||
An example of :ref:`patronictl_list` output for the Citus cluster::
|
An example of :ref:`patronictl_list` output for the Citus cluster::
|
||||||
|
|
||||||
postgres@coord1:~$ patronictl list demo
|
postgres@coord1:~$ patronictl list demo
|
||||||
+ Citus cluster: demo ----------+----------------+---------+----+-----------+
|
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||||
| 0 | coord2 | 172.27.0.6 | Quorum Standby | running | 1 | 0 |
|
| 0 | coord2 | 172.27.0.6 | Sync Standby | running | 1 | 0 |
|
||||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||||
| 1 | work1-1 | 172.27.0.8 | Quorum Standby | running | 1 | 0 |
|
| 1 | work1-1 | 172.27.0.8 | Sync Standby | running | 1 | 0 |
|
||||||
| 1 | work1-2 | 172.27.0.2 | Leader | running | 1 | |
|
| 1 | work1-2 | 172.27.0.2 | Leader | running | 1 | |
|
||||||
| 2 | work2-1 | 172.27.0.5 | Quorum Standby | running | 1 | 0 |
|
| 2 | work2-1 | 172.27.0.5 | Sync Standby | running | 1 | 0 |
|
||||||
| 2 | work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
| 2 | work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
|
|
||||||
If we add the ``--group`` option, the output will change to::
|
If we add the ``--group`` option, the output will change to::
|
||||||
|
|
||||||
postgres@coord1:~$ patronictl list demo --group 0
|
postgres@coord1:~$ patronictl list demo --group 0
|
||||||
+ Citus cluster: demo (group: 0, 7179854923829112860) -+-----------+
|
+ Citus cluster: demo (group: 0, 7179854923829112860) -----------+
|
||||||
| Member | Host | Role | State | TL | Lag in MB |
|
| Member | Host | Role | State | TL | Lag in MB |
|
||||||
+--------+-------------+----------------+---------+----+-----------+
|
+--------+-------------+--------------+---------+----+-----------+
|
||||||
| coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
| coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||||
| coord2 | 172.27.0.6 | Quorum Standby | running | 1 | 0 |
|
| coord2 | 172.27.0.6 | Sync Standby | running | 1 | 0 |
|
||||||
| coord3 | 172.27.0.4 | Leader | running | 1 | |
|
| coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||||
+--------+-------------+----------------+---------+----+-----------+
|
+--------+-------------+--------------+---------+----+-----------+
|
||||||
|
|
||||||
postgres@coord1:~$ patronictl list demo --group 1
|
postgres@coord1:~$ patronictl list demo --group 1
|
||||||
+ Citus cluster: demo (group: 1, 7179854923881963547) -+-----------+
|
+ Citus cluster: demo (group: 1, 7179854923881963547) -----------+
|
||||||
| Member | Host | Role | State | TL | Lag in MB |
|
| Member | Host | Role | State | TL | Lag in MB |
|
||||||
+---------+------------+----------------+---------+----+-----------+
|
+---------+------------+--------------+---------+----+-----------+
|
||||||
| work1-1 | 172.27.0.8 | Quorum Standby | running | 1 | 0 |
|
| work1-1 | 172.27.0.8 | Sync Standby | running | 1 | 0 |
|
||||||
| work1-2 | 172.27.0.2 | Leader | running | 1 | |
|
| work1-2 | 172.27.0.2 | Leader | running | 1 | |
|
||||||
+---------+------------+----------------+---------+----+-----------+
|
+---------+------------+--------------+---------+----+-----------+
|
||||||
|
|
||||||
Citus worker switchover
|
Citus worker switchover
|
||||||
-----------------------
|
-----------------------
|
||||||
@@ -124,28 +122,28 @@ new primary worker node is ready to accept read-write queries.
|
|||||||
An example of :ref:`patronictl_switchover` on the worker cluster::
|
An example of :ref:`patronictl_switchover` on the worker cluster::
|
||||||
|
|
||||||
postgres@coord1:~$ patronictl switchover demo
|
postgres@coord1:~$ patronictl switchover demo
|
||||||
+ Citus cluster: demo ----------+----------------+---------+----+-----------+
|
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||||
| 0 | coord2 | 172.27.0.6 | Quorum Standby | running | 1 | 0 |
|
| 0 | coord2 | 172.27.0.6 | Sync Standby | running | 1 | 0 |
|
||||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||||
| 1 | work1-1 | 172.27.0.8 | Leader | running | 1 | |
|
| 1 | work1-1 | 172.27.0.8 | Leader | running | 1 | |
|
||||||
| 1 | work1-2 | 172.27.0.2 | Quorum Standby | running | 1 | 0 |
|
| 1 | work1-2 | 172.27.0.2 | Sync Standby | running | 1 | 0 |
|
||||||
| 2 | work2-1 | 172.27.0.5 | Quorum Standby | running | 1 | 0 |
|
| 2 | work2-1 | 172.27.0.5 | Sync Standby | running | 1 | 0 |
|
||||||
| 2 | work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
| 2 | work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
Citus group: 2
|
Citus group: 2
|
||||||
Primary [work2-2]:
|
Primary [work2-2]:
|
||||||
Candidate ['work2-1'] []:
|
Candidate ['work2-1'] []:
|
||||||
When should the switchover take place (e.g. 2022-12-22T08:02 ) [now]:
|
When should the switchover take place (e.g. 2022-12-22T08:02 ) [now]:
|
||||||
Current cluster topology
|
Current cluster topology
|
||||||
+ Citus cluster: demo (group: 2, 7179854924063375386) -+-----------+
|
+ Citus cluster: demo (group: 2, 7179854924063375386) -----------+
|
||||||
| Member | Host | Role | State | TL | Lag in MB |
|
| Member | Host | Role | State | TL | Lag in MB |
|
||||||
+---------+------------+----------------+---------+----+-----------+
|
+---------+------------+--------------+---------+----+-----------+
|
||||||
| work2-1 | 172.27.0.5 | Quorum Standby | running | 1 | 0 |
|
| work2-1 | 172.27.0.5 | Sync Standby | running | 1 | 0 |
|
||||||
| work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
| work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||||
+---------+------------+----------------+---------+----+-----------+
|
+---------+------------+--------------+---------+----+-----------+
|
||||||
Are you sure you want to switchover cluster demo, demoting current primary work2-2? [y/N]: y
|
Are you sure you want to switchover cluster demo, demoting current primary work2-2? [y/N]: y
|
||||||
2022-12-22 07:02:40.33003 Successfully switched over to "work2-1"
|
2022-12-22 07:02:40.33003 Successfully switched over to "work2-1"
|
||||||
+ Citus cluster: demo (group: 2, 7179854924063375386) ------+
|
+ Citus cluster: demo (group: 2, 7179854924063375386) ------+
|
||||||
@@ -156,17 +154,17 @@ An example of :ref:`patronictl_switchover` on the worker cluster::
|
|||||||
+---------+------------+---------+---------+----+-----------+
|
+---------+------------+---------+---------+----+-----------+
|
||||||
|
|
||||||
postgres@coord1:~$ patronictl list demo
|
postgres@coord1:~$ patronictl list demo
|
||||||
+ Citus cluster: demo ----------+----------------+---------+----+-----------+
|
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||||
| Group | Member | Host | Role | State | TL | Lag in MB |
|
| Group | Member | Host | Role | State | TL | Lag in MB |
|
||||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
| 0 | coord1 | 172.27.0.10 | Replica | running | 1 | 0 |
|
||||||
| 0 | coord2 | 172.27.0.6 | Quorum Standby | running | 1 | 0 |
|
| 0 | coord2 | 172.27.0.6 | Sync Standby | running | 1 | 0 |
|
||||||
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
| 0 | coord3 | 172.27.0.4 | Leader | running | 1 | |
|
||||||
| 1 | work1-1 | 172.27.0.8 | Leader | running | 1 | |
|
| 1 | work1-1 | 172.27.0.8 | Leader | running | 1 | |
|
||||||
| 1 | work1-2 | 172.27.0.2 | Quorum Standby | running | 1 | 0 |
|
| 1 | work1-2 | 172.27.0.2 | Sync Standby | running | 1 | 0 |
|
||||||
| 2 | work2-1 | 172.27.0.5 | Leader | running | 2 | |
|
| 2 | work2-1 | 172.27.0.5 | Leader | running | 2 | |
|
||||||
| 2 | work2-2 | 172.27.0.7 | Quorum Standby | running | 2 | 0 |
|
| 2 | work2-2 | 172.27.0.7 | Sync Standby | running | 2 | 0 |
|
||||||
+-------+---------+-------------+----------------+---------+----+-----------+
|
+-------+---------+-------------+--------------+---------+----+-----------+
|
||||||
|
|
||||||
And this is how it looks on the coordinator side::
|
And this is how it looks on the coordinator side::
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ In order to change the dynamic configuration you can use either :ref:`patronictl
|
|||||||
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
|
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
|
||||||
- **primary\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + primary\_start\_timeout + loop\_wait, unless primary\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
- **primary\_start\_timeout**: the amount of time a primary is allowed to recover from failures before failover is triggered (in seconds). Default is 300 seconds. When set to 0 failover is done immediately after a crash is detected if possible. When using asynchronous replication a failover can cause lost transactions. Worst case failover time for primary failure is: loop\_wait + primary\_start\_timeout + loop\_wait, unless primary\_start\_timeout is zero, in which case it's just loop\_wait. Set the value according to your durability/availability tradeoff.
|
||||||
- **primary\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by primary\_stop\_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, primary\_stop\_timeout does not apply.
|
- **primary\_stop\_timeout**: The number of seconds Patroni is allowed to wait when stopping Postgres and effective only when synchronous_mode is enabled. When set to > 0 and the synchronous_mode is enabled, Patroni sends SIGKILL to the postmaster if the stop operation is running for more than the value set by primary\_stop\_timeout. Set the value according to your durability/availability tradeoff. If the parameter is not set or set <= 0, primary\_stop\_timeout does not apply.
|
||||||
- **synchronous\_mode**: turns on synchronous replication mode. Possible values: ``off``, ``on``, ``quorum``. In this mode the leader takes care of management of ``synchronous_standby_names``, and only the last known leader, or one of synchronous replicas, are allowed to participate in leader race. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
|
- **synchronous\_mode**: turns on synchronous replication mode. In this mode a replica will be chosen as synchronous and only the latest leader and synchronous replica are able to participate in leader election. Synchronous mode makes sure that successfully committed transactions will not be lost at failover, at the cost of losing availability for writes when Patroni cannot ensure transaction durability. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||||
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
|
- **synchronous\_mode\_strict**: prevents disabling synchronous replication if no synchronous replicas are available, blocking all client writes to the primary. See :ref:`replication modes documentation <replication_modes>` for details.
|
||||||
- **failsafe\_mode**: Enables :ref:`DCS Failsafe Mode <dcs_failsafe_mode>`. Defaults to `false`.
|
- **failsafe\_mode**: Enables :ref:`DCS Failsafe Mode <dcs_failsafe_mode>`. Defaults to `false`.
|
||||||
- **postgresql**:
|
- **postgresql**:
|
||||||
@@ -108,7 +108,3 @@ Note: if cluster topology is static (fixed number of nodes that never change the
|
|||||||
.. warning::
|
.. warning::
|
||||||
Permanent replication slots are synchronized only from the ``primary``/``standby_leader`` to replica nodes. That means, applications are supposed to be using them only from the leader node. Using them on replica nodes will cause indefinite growth of ``pg_wal`` on all other nodes in the cluster.
|
Permanent replication slots are synchronized only from the ``primary``/``standby_leader`` to replica nodes. That means, applications are supposed to be using them only from the leader node. Using them on replica nodes will cause indefinite growth of ``pg_wal`` on all other nodes in the cluster.
|
||||||
An exception to that rule are permanent physical slots that match the Patroni member names, if you happen to configure any. Those will be synchronized among all nodes as they are used for replication among them.
|
An exception to that rule are permanent physical slots that match the Patroni member names, if you happen to configure any. Those will be synchronized among all nodes as they are used for replication among them.
|
||||||
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
Setting ``nostream`` tag on standby disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas if any.
|
|
||||||
|
|||||||
@@ -28,14 +28,12 @@ Currently supported PostgreSQL versions: 9.3 to 16.
|
|||||||
patronictl
|
patronictl
|
||||||
replica_bootstrap
|
replica_bootstrap
|
||||||
replication_modes
|
replication_modes
|
||||||
standby_cluster
|
|
||||||
watchdog
|
watchdog
|
||||||
pause
|
pause
|
||||||
dcs_failsafe_mode
|
dcs_failsafe_mode
|
||||||
kubernetes
|
kubernetes
|
||||||
citus
|
citus
|
||||||
existing_data
|
existing_data
|
||||||
tools_integration
|
|
||||||
security
|
security
|
||||||
ha_multi_dc
|
ha_multi_dc
|
||||||
faq
|
faq
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ raft
|
|||||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||||
aws
|
aws
|
||||||
`boto3` in order to use AWS callbacks
|
`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
|
||||||
all of the above (except psycopg family)
|
all of the above (except psycopg family)
|
||||||
psycopg
|
psycopg
|
||||||
|
|||||||
+56
-35
@@ -1,5 +1,3 @@
|
|||||||
.. _replica_imaging_and_bootstrap:
|
|
||||||
|
|
||||||
Replica imaging and bootstrap
|
Replica imaging and bootstrap
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
@@ -73,21 +71,6 @@ Makes the configured ``command`` to be called additionally with ``--arg1=value1
|
|||||||
|
|
||||||
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
|
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
|
||||||
|
|
||||||
As an example, you are able to bootstrap a fresh Patroni cluster from a Barman backup with a configuration like this:
|
|
||||||
|
|
||||||
.. code:: YAML
|
|
||||||
|
|
||||||
bootstrap:
|
|
||||||
method: barman
|
|
||||||
barman:
|
|
||||||
keep_existing_recovery_conf: true
|
|
||||||
command: patroni_barman --api-url https://barman-host:7480 recover
|
|
||||||
barman-server: my_server
|
|
||||||
ssh-command: ssh postgres@patroni-host
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
``patroni_barman recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
|
|
||||||
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman recover --help``.
|
|
||||||
|
|
||||||
.. _custom_replica_creation:
|
.. _custom_replica_creation:
|
||||||
|
|
||||||
@@ -142,24 +125,6 @@ example: pgbackrest
|
|||||||
basebackup:
|
basebackup:
|
||||||
max-rate: '100M'
|
max-rate: '100M'
|
||||||
|
|
||||||
example: Barman
|
|
||||||
|
|
||||||
.. code:: YAML
|
|
||||||
|
|
||||||
postgresql:
|
|
||||||
create_replica_methods:
|
|
||||||
- barman
|
|
||||||
- basebackup
|
|
||||||
barman:
|
|
||||||
command: patroni_barman --api-url https://barman-host:7480 recover
|
|
||||||
barman-server: my_server
|
|
||||||
ssh-command: ssh postgres@patroni-host
|
|
||||||
basebackup:
|
|
||||||
max-rate: '100M'
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
``patroni_barman recover`` requires that you have both Barman and ``pg-backup-api`` configured in the Barman host, so it can execute a remote ``barman recover`` through the backup API.
|
|
||||||
The above example uses a subset of the available parameters. You can get more information running ``patroni_barman recover --help``.
|
|
||||||
|
|
||||||
The ``create_replica_methods`` defines available replica creation methods and the order of executing them. Patroni will
|
The ``create_replica_methods`` defines available replica creation methods and the order of executing them. Patroni will
|
||||||
stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command
|
stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command
|
||||||
@@ -219,3 +184,59 @@ and
|
|||||||
- waldir: /pg-wal-mount/external-waldir
|
- waldir: /pg-wal-mount/external-waldir
|
||||||
|
|
||||||
If all replica creation methods fail, Patroni will try again all methods in order during the next event loop cycle.
|
If all replica creation methods fail, Patroni will try again all methods in order during the next event loop cycle.
|
||||||
|
|
||||||
|
.. _standby_cluster:
|
||||||
|
|
||||||
|
Standby cluster
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Another available option is to run a "standby cluster", that contains only of
|
||||||
|
standby nodes replicating from some remote node. This type of clusters has:
|
||||||
|
|
||||||
|
* "standby leader", that behaves pretty much like a regular cluster leader,
|
||||||
|
except it replicates from a remote node.
|
||||||
|
|
||||||
|
* cascade replicas, that are replicating from standby leader.
|
||||||
|
|
||||||
|
Standby leader holds and updates a leader lock in DCS. If the leader lock
|
||||||
|
expires, cascade replicas will perform an election to choose another leader
|
||||||
|
from the standbys.
|
||||||
|
|
||||||
|
There is no further relationship between the standby cluster and the primary
|
||||||
|
cluster it replicates from, in particular, they must not share the same DCS
|
||||||
|
scope if they use the same DCS. They do not know anything else from each other
|
||||||
|
apart from replication information. Also, the standby cluster is not being
|
||||||
|
displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
|
||||||
|
primary cluster.
|
||||||
|
|
||||||
|
For the sake of flexibility, you can specify methods of creating a replica and
|
||||||
|
recovery WAL records when a cluster is in the "standby mode" by providing
|
||||||
|
`create_replica_methods` key in `standby_cluster` section. It is distinct from
|
||||||
|
creating replicas, when cluster is detached and functions as a normal cluster,
|
||||||
|
which is controlled by `create_replica_methods` in `postgresql` section. Both
|
||||||
|
"standby" and "normal" `create_replica_methods` reference keys in `postgresql`
|
||||||
|
section.
|
||||||
|
|
||||||
|
To configure such cluster you need to specify the section ``standby_cluster``
|
||||||
|
in a patroni configuration:
|
||||||
|
|
||||||
|
.. code:: YAML
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
dcs:
|
||||||
|
standby_cluster:
|
||||||
|
host: 1.2.3.4
|
||||||
|
port: 5432
|
||||||
|
primary_slot_name: patroni
|
||||||
|
create_replica_methods:
|
||||||
|
- basebackup
|
||||||
|
|
||||||
|
Note, that these options will be applied only once during cluster bootstrap,
|
||||||
|
and the only way to change them afterwards is through DCS.
|
||||||
|
|
||||||
|
Patroni expects to find `postgresql.conf` or `postgresql.conf.backup` in PGDATA
|
||||||
|
of the remote primary and will not start if it does not find it after a
|
||||||
|
basebackup. If the remote primary keeps its `postgresql.conf` elsewhere, it is
|
||||||
|
your responsibility to copy it to PGDATA.
|
||||||
|
|
||||||
|
If you use replication slots on the standby cluster, you must also create the corresponding replication slot on the primary cluster. It will not be done automatically by the standby cluster implementation. You can use Patroni's permanent replication slots feature on the primary cluster to maintain a replication slot with the same name as ``primary_slot_name``, or its default value if ``primary_slot_name`` is not provided.
|
||||||
|
|||||||
+12
-109
@@ -6,9 +6,8 @@ Replication modes
|
|||||||
|
|
||||||
Patroni uses PostgreSQL streaming replication. For more information about streaming replication, see the `Postgres documentation <http://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION>`__. By default Patroni configures PostgreSQL for asynchronous replication. Choosing your replication schema is dependent on your business considerations. Investigate both async and sync replication, as well as other HA solutions, to determine which solution is best for you.
|
Patroni uses PostgreSQL streaming replication. For more information about streaming replication, see the `Postgres documentation <http://www.postgresql.org/docs/current/static/warm-standby.html#STREAMING-REPLICATION>`__. By default Patroni configures PostgreSQL for asynchronous replication. Choosing your replication schema is dependent on your business considerations. Investigate both async and sync replication, as well as other HA solutions, to determine which solution is best for you.
|
||||||
|
|
||||||
|
|
||||||
Asynchronous mode durability
|
Asynchronous mode durability
|
||||||
============================
|
----------------------------
|
||||||
|
|
||||||
In asynchronous mode the cluster is allowed to lose some committed transactions to ensure availability. When the primary server fails or becomes unavailable for any other reason Patroni will automatically promote a sufficiently healthy standby to primary. Any transactions that have not been replicated to that standby remain in a "forked timeline" on the primary, and are effectively unrecoverable [1]_.
|
In asynchronous mode the cluster is allowed to lose some committed transactions to ensure availability. When the primary server fails or becomes unavailable for any other reason Patroni will automatically promote a sufficiently healthy standby to primary. Any transactions that have not been replicated to that standby remain in a "forked timeline" on the primary, and are effectively unrecoverable [1]_.
|
||||||
|
|
||||||
@@ -16,11 +15,10 @@ The amount of transactions that can be lost is controlled via ``maximum_lag_on_f
|
|||||||
|
|
||||||
By default, when running leader elections, Patroni does not take into account the current timeline of replicas, what in some cases could be undesirable behavior. You can prevent the node not having the same timeline as a former primary become the new leader by changing the value of ``check_timeline`` parameter to ``true``.
|
By default, when running leader elections, Patroni does not take into account the current timeline of replicas, what in some cases could be undesirable behavior. You can prevent the node not having the same timeline as a former primary become the new leader by changing the value of ``check_timeline`` parameter to ``true``.
|
||||||
|
|
||||||
|
|
||||||
PostgreSQL synchronous replication
|
PostgreSQL synchronous replication
|
||||||
==================================
|
----------------------------------
|
||||||
|
|
||||||
You can use Postgres's `synchronous replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__ with Patroni. Synchronous replication ensures consistency across a cluster by confirming that writes are written to a secondary before returning to the connecting client with a success. The cost of synchronous replication: increased latency and reduced throughput on writes. This throughput will be entirely based on network performance.
|
You can use Postgres's `synchronous replication <http://www.postgresql.org/docs/current/static/warm-standby.html#SYNCHRONOUS-REPLICATION>`__ with Patroni. Synchronous replication ensures consistency across a cluster by confirming that writes are written to a secondary before returning to the connecting client with a success. The cost of synchronous replication: reduced throughput on writes. This throughput will be entirely based on network performance.
|
||||||
|
|
||||||
In hosted datacenter environments (like AWS, Rackspace, or any network you do not control), synchronous replication significantly increases the variability of write performance. If followers become inaccessible from the leader, the leader effectively becomes read-only.
|
In hosted datacenter environments (like AWS, Rackspace, or any network you do not control), synchronous replication significantly increases the variability of write performance. If followers become inaccessible from the leader, the leader effectively becomes read-only.
|
||||||
|
|
||||||
@@ -35,11 +33,10 @@ When using PostgreSQL synchronous replication, use at least three Postgres data
|
|||||||
|
|
||||||
Using PostgreSQL synchronous replication does not guarantee zero lost transactions under all circumstances. When the primary and the secondary that is currently acting as a synchronous replica fail simultaneously a third node that might not contain all transactions will be promoted.
|
Using PostgreSQL synchronous replication does not guarantee zero lost transactions under all circumstances. When the primary and the secondary that is currently acting as a synchronous replica fail simultaneously a third node that might not contain all transactions will be promoted.
|
||||||
|
|
||||||
|
|
||||||
.. _synchronous_mode:
|
.. _synchronous_mode:
|
||||||
|
|
||||||
Synchronous mode
|
Synchronous mode
|
||||||
================
|
----------------
|
||||||
|
|
||||||
For use cases where losing committed transactions is not permissible you can turn on Patroni's ``synchronous_mode``. When ``synchronous_mode`` is turned on Patroni will not promote a standby unless it is certain that the standby contains all transactions that may have returned a successful commit status to client [2]_. This means that the system may be unavailable for writes even though some servers are available. System administrators can still use manual failover commands to promote a standby even if it results in transaction loss.
|
For use cases where losing committed transactions is not permissible you can turn on Patroni's ``synchronous_mode``. When ``synchronous_mode`` is turned on Patroni will not promote a standby unless it is certain that the standby contains all transactions that may have returned a successful commit status to client [2]_. This means that the system may be unavailable for writes even though some servers are available. System administrators can still use manual failover commands to promote a standby even if it results in transaction loss.
|
||||||
|
|
||||||
@@ -56,124 +53,30 @@ are available. As a downside, the primary is not be available for writes
|
|||||||
blocking all client write requests until at least one synchronous replica comes
|
blocking all client write requests until at least one synchronous replica comes
|
||||||
up.
|
up.
|
||||||
|
|
||||||
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby. Setting tag ``nostream`` to true will also have the same effect.
|
You can ensure that a standby never becomes the synchronous standby by setting ``nosync`` tag to true. This is recommended to set for standbys that are behind slow network connections and would cause performance degradation when becoming a synchronous standby.
|
||||||
|
|
||||||
Synchronous mode can be switched on and off using ``patronictl edit-config`` command or via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
|
Synchronous mode can be switched on and off via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
|
||||||
|
|
||||||
Note: Because of the way synchronous replication is implemented in PostgreSQL it is still possible to lose transactions even when using ``synchronous_mode_strict``. If the PostgreSQL backend is cancelled while waiting to acknowledge replication (as a result of packet cancellation due to client timeout or backend failure) transaction changes become visible for other backends. Such changes are not yet replicated and may be lost in case of standby promotion.
|
Note: Because of the way synchronous replication is implemented in PostgreSQL it is still possible to lose transactions even when using ``synchronous_mode_strict``. If the PostgreSQL backend is cancelled while waiting to acknowledge replication (as a result of packet cancellation due to client timeout or backend failure) transaction changes become visible for other backends. Such changes are not yet replicated and may be lost in case of standby promotion.
|
||||||
|
|
||||||
|
|
||||||
Synchronous Replication Factor
|
Synchronous Replication Factor
|
||||||
==============================
|
------------------------------
|
||||||
|
The parameter ``synchronous_node_count`` is used by Patroni to manage number of synchronous standby databases. It is set to 1 by default. It has no effect when ``synchronous_mode`` is set to off. When enabled, Patroni manages precise number of synchronous standby databases based on parameter ``synchronous_node_count`` and adjusts the state in DCS & synchronous_standby_names as members join and leave.
|
||||||
The parameter ``synchronous_node_count`` is used by Patroni to manage the number of synchronous standby databases. It is set to ``1`` by default. It has no effect when ``synchronous_mode`` is set to ``off``. When enabled, Patroni manages the precise number of synchronous standby databases based on parameter ``synchronous_node_count`` and adjusts the state in DCS & ``synchronous_standby_names`` in PostgreSQL as members join and leave. If the parameter is set to a value higher than the number of eligible nodes it will be automatically reduced by Patroni.
|
|
||||||
|
|
||||||
|
|
||||||
Maximum lag on synchronous node
|
|
||||||
===============================
|
|
||||||
|
|
||||||
By default Patroni sticks to nodes that are declared as ``synchronous``, according to the ``pg_stat_replication`` view, even when there are other nodes ahead of it. This is done to minimize the number of changes of ``synchronous_standby_names``. To change this behavior one may use ``maximum_lag_on_syncnode`` parameter. It controls how much lag the replica can have to still be considered as "synchronous".
|
|
||||||
|
|
||||||
Patroni utilizes the max replica LSN if there is more than one standby, otherwise it will use leader's current wal LSN. The default is ``-1``, and Patroni will not take action to swap a synchronous unhealthy standby when the value is set to ``0`` or less. Please set the value high enough so that Patroni won't swap synchronous standbys frequently during high transaction volume.
|
|
||||||
|
|
||||||
|
|
||||||
Synchronous mode implementation
|
Synchronous mode implementation
|
||||||
===============================
|
-------------------------------
|
||||||
|
|
||||||
When in synchronous mode Patroni maintains synchronization state in the DCS (``/sync`` key), containing the latest primary and current synchronous standby databases. This state is updated with strict ordering constraints to ensure the following invariants:
|
When in synchronous mode Patroni maintains synchronization state in the DCS, containing the latest primary and current synchronous standby databases. This state is updated with strict ordering constraints to ensure the following invariants:
|
||||||
|
|
||||||
- A node must be marked as the latest leader whenever it can accept write transactions. Patroni crashing or PostgreSQL not shutting down can cause violations of this invariant.
|
- A node must be marked as the latest leader whenever it can accept write transactions. Patroni crashing or PostgreSQL not shutting down can cause violations of this invariant.
|
||||||
|
|
||||||
- A node must be set as the synchronous standby in PostgreSQL as long as it is published as the synchronous standby in the ``/sync`` key in DCS..
|
- A node must be set as the synchronous standby in PostgreSQL as long as it is published as the synchronous standby.
|
||||||
|
|
||||||
- A node that is not the leader or current synchronous standby is not allowed to promote itself automatically.
|
- A node that is not the leader or current synchronous standby is not allowed to promote itself automatically.
|
||||||
|
|
||||||
Patroni will only assign one or more synchronous standby nodes based on ``synchronous_node_count`` parameter to ``synchronous_standby_names``.
|
Patroni will only assign one or more synchronous standby nodes based on ``synchronous_node_count`` parameter to ``synchronous_standby_names``.
|
||||||
|
|
||||||
On each HA loop iteration Patroni re-evaluates synchronous standby nodes choice. If the current list of synchronous standby nodes are connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster members available for sync that are furthest ahead in replication are picked.
|
On each HA loop iteration Patroni re-evaluates synchronous standby nodes choice. If the current list of synchronous standby nodes are connected and has not requested its synchronous status to be removed it remains picked. Otherwise the cluster member available for sync that is furthest ahead in replication is picked.
|
||||||
|
|
||||||
Example:
|
|
||||||
---------
|
|
||||||
|
|
||||||
``/config`` key in DCS
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. code-block:: YAML
|
|
||||||
|
|
||||||
synchronous_mode: on
|
|
||||||
synchronous_node_count: 2
|
|
||||||
...
|
|
||||||
|
|
||||||
``/sync`` key in DCS
|
|
||||||
^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. code-block:: JSON
|
|
||||||
|
|
||||||
{
|
|
||||||
"leader": "node0",
|
|
||||||
"sync_standby": "node1,node2"
|
|
||||||
}
|
|
||||||
|
|
||||||
postgresql.conf
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. code-block:: INI
|
|
||||||
|
|
||||||
synchronous_standby_names = 'FIRST 2 (node1,node2)'
|
|
||||||
|
|
||||||
|
|
||||||
In the above examples only nodes ``node1`` and ``node2`` are known to be synchronous and allowed to be automatically promoted if the primary (``node0``) fails.
|
|
||||||
|
|
||||||
|
|
||||||
.. _quorum_mode:
|
|
||||||
|
|
||||||
Quorum commit mode
|
|
||||||
==================
|
|
||||||
|
|
||||||
Starting from PostgreSQL v10 Patroni supports quorum-based synchronous replication.
|
|
||||||
|
|
||||||
In this mode, Patroni maintains synchronization state in the DCS, containing the latest known primary, the number of nodes required for quorum, and the nodes currently eligible to vote on quorum. In steady state, the nodes voting on quorum are the leader and all synchronous standbys. This state is updated with strict ordering constraints, with regards to node promotion and ``synchronous_standby_names``, to ensure that at all times any subset of voters that can achieve quorum includes at least one node with the latest successful commit.
|
|
||||||
|
|
||||||
On each iteration of HA loop, Patroni re-evaluates synchronous standby choices and quorum, based on node availability and requested cluster configuration. In PostgreSQL versions above 9.6 all eligible nodes are added as synchronous standbys as soon as their replication catches up to leader.
|
|
||||||
|
|
||||||
Quorum commit helps to reduce worst case latencies, even during normal operation, as a higher latency of replicating to one standby can be compensated by other standbys.
|
|
||||||
|
|
||||||
The quorum-based synchronous mode could be enabled by setting ``synchronous_mode`` to ``quorum`` using ``patronictl edit-config`` command or via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
|
|
||||||
|
|
||||||
Other parameters, like ``synchronous_node_count``, ``maximum_lag_on_syncnode``, and ``synchronous_mode_strict`` continue to work the same way as with ``synchronous_mode=on``.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
---------
|
|
||||||
|
|
||||||
``/config`` key in DCS
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. code-block:: YAML
|
|
||||||
|
|
||||||
synchronous_mode: quorum
|
|
||||||
synchronous_node_count: 2
|
|
||||||
...
|
|
||||||
|
|
||||||
``/sync`` key in DCS
|
|
||||||
^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. code-block:: JSON
|
|
||||||
|
|
||||||
{
|
|
||||||
"leader": "node0",
|
|
||||||
"sync_standby": "node1,node2,node3",
|
|
||||||
"quorum": 1
|
|
||||||
}
|
|
||||||
|
|
||||||
postgresql.conf
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. code-block:: INI
|
|
||||||
|
|
||||||
synchronous_standby_names = 'ANY 2 (node1,node2,node3)'
|
|
||||||
|
|
||||||
|
|
||||||
If the primary (``node0``) failed, in the above example two of the ``node1``, ``node2``, ``node3`` will have the latest transaction received, but we don't know which ones. To figure out whether the node ``node1`` has received the latest transaction, we need to compare its LSN with the LSN on **at least** one node (``quorum=1`` in the ``/sync`` key) among ``node2`` and ``node3``. If ``node1`` isn't behind of at least one of them, we can guarantee that there will be no user visible data loss if ``node1`` is promoted.
|
|
||||||
|
|
||||||
|
|
||||||
.. [1] The data is still there, but recovering it requires a manual recovery effort by data recovery specialists. When Patroni is allowed to rewind with ``use_pg_rewind`` the forked timeline will be automatically erased to rejoin the failed primary with the cluster.
|
.. [1] The data is still there, but recovering it requires a manual recovery effort by data recovery specialists. When Patroni is allowed to rewind with ``use_pg_rewind`` the forked timeline will be automatically erased to rejoin the failed primary with the cluster.
|
||||||
|
|||||||
@@ -45,10 +45,6 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
|
|||||||
|
|
||||||
- ``GET /read-only-sync``: like the above endpoint, but also includes the primary.
|
- ``GET /read-only-sync``: like the above endpoint, but also includes the primary.
|
||||||
|
|
||||||
- ``GET /quorum``: returns HTTP status code **200** only when this Patroni node is listed as a quorum node in ``synchronous_standby_names`` on the primary.
|
|
||||||
|
|
||||||
- ``GET /read-only-quorum``: like the above endpoint, but also includes the primary.
|
|
||||||
|
|
||||||
- ``GET /asynchronous`` or ``GET /async``: returns HTTP status code **200** only when the Patroni node is running as an asynchronous standby.
|
- ``GET /asynchronous`` or ``GET /async``: returns HTTP status code **200** only when the Patroni node is running as an asynchronous standby.
|
||||||
|
|
||||||
|
|
||||||
@@ -312,9 +308,6 @@ Retrieve the Patroni metrics in Prometheus format through the ``GET /metrics`` e
|
|||||||
# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.
|
# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.
|
||||||
# TYPE patroni_sync_standby gauge
|
# TYPE patroni_sync_standby gauge
|
||||||
patroni_sync_standby{scope="batman",name="patroni1"} 0
|
patroni_sync_standby{scope="batman",name="patroni1"} 0
|
||||||
# HELP patroni_quorum_standby Value is 1 if this node is a quorum standby replica, 0 otherwise.
|
|
||||||
# TYPE patroni_quorum_standby gauge
|
|
||||||
patroni_quorum_standby{scope="batman",name="patroni1"} 0
|
|
||||||
# HELP patroni_xlog_received_location Current location of the received Postgres transaction log, 0 if this node is not a replica.
|
# HELP patroni_xlog_received_location Current location of the received Postgres transaction log, 0 if this node is not a replica.
|
||||||
# TYPE patroni_xlog_received_location counter
|
# TYPE patroni_xlog_received_location counter
|
||||||
patroni_xlog_received_location{scope="batman",name="patroni1"} 0
|
patroni_xlog_received_location{scope="batman",name="patroni1"} 0
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
.. _standby_cluster:
|
|
||||||
|
|
||||||
Standby cluster
|
|
||||||
---------------
|
|
||||||
|
|
||||||
Patroni also support running cascading replication to a remote datacenter
|
|
||||||
(region) using a feature that is called "standby cluster". This type of
|
|
||||||
clusters has:
|
|
||||||
|
|
||||||
* "standby leader", that behaves pretty much like a regular cluster leader,
|
|
||||||
except it replicates from a remote node.
|
|
||||||
|
|
||||||
* cascade replicas, that are replicating from standby leader.
|
|
||||||
|
|
||||||
Standby leader holds and updates a leader lock in DCS. If the leader lock
|
|
||||||
expires, cascade replicas will perform an election to choose another leader
|
|
||||||
from the standbys.
|
|
||||||
|
|
||||||
There is no further relationship between the standby cluster and the primary
|
|
||||||
cluster it replicates from, in particular, they must not share the same DCS
|
|
||||||
scope if they use the same DCS. They do not know anything else from each other
|
|
||||||
apart from replication information. Also, the standby cluster is not being
|
|
||||||
displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
|
|
||||||
primary cluster.
|
|
||||||
|
|
||||||
For the sake of flexibility, you can specify methods of creating a replica and
|
|
||||||
recovery WAL records when a cluster is in the "standby mode" by providing
|
|
||||||
:ref:`create_replica_methods <custom_replica_creation>` key in
|
|
||||||
`standby_cluster` section. It is distinct from creating replicas, when cluster
|
|
||||||
is detached and functions as a normal cluster, which is controlled by
|
|
||||||
`create_replica_methods` in `postgresql` section. Both "standby" and "normal"
|
|
||||||
`create_replica_methods` reference keys in `postgresql` section.
|
|
||||||
|
|
||||||
To configure such cluster you need to specify the section ``standby_cluster``
|
|
||||||
in a patroni configuration:
|
|
||||||
|
|
||||||
.. code:: YAML
|
|
||||||
|
|
||||||
bootstrap:
|
|
||||||
dcs:
|
|
||||||
standby_cluster:
|
|
||||||
host: 1.2.3.4
|
|
||||||
port: 5432
|
|
||||||
primary_slot_name: patroni
|
|
||||||
create_replica_methods:
|
|
||||||
- basebackup
|
|
||||||
|
|
||||||
Note, that these options will be applied only once during cluster bootstrap,
|
|
||||||
and the only way to change them afterwards is through DCS.
|
|
||||||
|
|
||||||
Patroni expects to find `postgresql.conf` or `postgresql.conf.backup` in PGDATA
|
|
||||||
of the remote primary and will not start if it does not find it after a
|
|
||||||
basebackup. If the remote primary keeps its `postgresql.conf` elsewhere, it is
|
|
||||||
your responsibility to copy it to PGDATA.
|
|
||||||
|
|
||||||
If you use replication slots on the standby cluster, you must also create the
|
|
||||||
corresponding replication slot on the primary cluster. It will not be done
|
|
||||||
automatically by the standby cluster implementation. You can use Patroni's
|
|
||||||
permanent replication slots feature on the primary cluster to maintain a
|
|
||||||
replication slot with the same name as ``primary_slot_name``, or its default
|
|
||||||
value if ``primary_slot_name`` is not provided.
|
|
||||||
|
|
||||||
In case the remote site doesn't provide a single endpoint that connects to a
|
|
||||||
primary, one could list all hosts of the source cluster in the
|
|
||||||
``standby_cluster.host`` section. When ``standby_cluster.host`` contains
|
|
||||||
multiple hosts separated by commas, Patroni will:
|
|
||||||
|
|
||||||
* add ``target_session_attrs=read-write`` to the ``primary_conninfo`` on the
|
|
||||||
standby leader node.
|
|
||||||
* use ``target_session_attrs=read-write`` when trying to determine whether we
|
|
||||||
need to run ``pg_rewind`` or when executing ``pg_rewind`` on all nodes of the
|
|
||||||
standby cluster.
|
|
||||||
|
|
||||||
There is also a possibility to replicate the standby cluster from another
|
|
||||||
standby cluster or from a standby member of the primary cluster: for that, you
|
|
||||||
need to define a single host in the ``standby_cluster.host`` section. However,
|
|
||||||
you need to beware that in this case ``pg_rewind`` will fail to execute on the
|
|
||||||
standby cluster.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
Integration with other tools
|
|
||||||
============================
|
|
||||||
|
|
||||||
Patroni is able to integrate with other tools in your stack. In this section you
|
|
||||||
will find a list of examples, which although not an exhaustive list, might
|
|
||||||
provide you with ideas on how Patroni can integrate with other tools.
|
|
||||||
|
|
||||||
Barman
|
|
||||||
------
|
|
||||||
|
|
||||||
Patroni delivers an application named ``patroni_barman`` which has logic to
|
|
||||||
communicate with ``pg-backup-api``, so you are able to perform Barman operations
|
|
||||||
remotely.
|
|
||||||
|
|
||||||
This application currently has a couple of sub-commands: ``recover`` and
|
|
||||||
``config-switch``.
|
|
||||||
|
|
||||||
patroni_barman recover
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
The ``recover`` sub-command can be used as a custom bootstrap or custom replica
|
|
||||||
creation method. You can find more information about that in
|
|
||||||
:ref:`replica_imaging_and_bootstrap`.
|
|
||||||
|
|
||||||
patroni_barman config-switch
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
The ``config-switch`` sub-command is designed to be used as an ``on_role_change``
|
|
||||||
callback in Patroni. As an example, assume you are streaming WALs from your
|
|
||||||
current primary to your Barman host. In the event of a failover in the cluster
|
|
||||||
you might want to start streaming WALs from the new primary. You can accomplish
|
|
||||||
this by using ``patroni_barman config-switch`` as the ``on_role_change`` callback.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
That sub-command relies on the ``barman config-switch`` command, which is in
|
|
||||||
charge of overriding the configuration of a Barman server by applying a
|
|
||||||
pre-defined model on top of it. This command is available since Barman 3.10.
|
|
||||||
Please consult the Barman documentation for more details.
|
|
||||||
|
|
||||||
This is an example of how you can configure Patroni to apply a configuration
|
|
||||||
model in case this Patroni node is promoted to primary:
|
|
||||||
|
|
||||||
.. code:: YAML
|
|
||||||
|
|
||||||
postgresql:
|
|
||||||
callbacks:
|
|
||||||
on_role_change: >
|
|
||||||
patroni_barman
|
|
||||||
--api-url YOUR_API_URL
|
|
||||||
config-switch
|
|
||||||
--barman-server YOUR_BARMAN_SERVER_NAME
|
|
||||||
--barman-model YOUR_BARMAN_MODEL_NAME
|
|
||||||
--switch-when promoted
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
``patroni_barman config-switch`` requires that you have both Barman and
|
|
||||||
``pg-backup-api`` configured in the Barman host, so it can execute a remote
|
|
||||||
``barman config-switch`` through the backup API. Also, it requires that you
|
|
||||||
have pre-configured Barman models to be applied. The above example uses a
|
|
||||||
subset of the available parameters. You can get more information running
|
|
||||||
``patroni_barman config-switch --help``, and by consulting the Barman
|
|
||||||
documentation.
|
|
||||||
@@ -11,22 +11,12 @@ Global/Universal
|
|||||||
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
|
||||||
- **scope**: cluster name
|
- **scope**: cluster name
|
||||||
|
|
||||||
.. _log_settings:
|
|
||||||
|
|
||||||
Log
|
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>`_)
|
- **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**.
|
- **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. If the log type is **plain**, the log format should be a string. Refer to
|
- **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>`_)
|
||||||
`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>`_)
|
- **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.
|
- **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).
|
- **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.
|
- **file\_num**: The number of application logs to retain.
|
||||||
@@ -36,20 +26,6 @@ Log
|
|||||||
- **patroni.postmaster: WARNING**
|
- **patroni.postmaster: WARNING**
|
||||||
- **urllib3: DEBUG**
|
- **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_settings:
|
||||||
|
|
||||||
Bootstrap configuration
|
Bootstrap configuration
|
||||||
@@ -157,7 +133,6 @@ ZooKeeper
|
|||||||
- **key_password**: (optional) The client key password.
|
- **key_password**: (optional) The client key password.
|
||||||
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
|
||||||
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
|
||||||
- **auth_data**: (optional) Authentication credentials to use for the connection. Should be a dictionary in the form that `scheme` is the key and `credential` is the value. Defaults to empty dictionary.
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
It is required to install ``kazoo>=2.6.0`` to support SSL.
|
||||||
@@ -399,7 +374,6 @@ Tags
|
|||||||
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
||||||
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races.
|
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races.
|
||||||
- **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
|
- **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
|
||||||
- **nostream**: ``true`` or ``false``. If set to ``true`` the node will not use replication protocol to stream WAL. It will rely instead on archive recovery (if ``restore_command`` is configured) and ``pg_wal``/``pg_xlog`` polling. It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas. Setting this tag on primary node has no effect.
|
|
||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1.
|
Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1.
|
||||||
|
|||||||
+7
-10
@@ -256,18 +256,10 @@ class PatroniController(AbstractController):
|
|||||||
'parameters': {
|
'parameters': {
|
||||||
'wal_keep_segments': 100,
|
'wal_keep_segments': 100,
|
||||||
'archive_mode': 'on',
|
'archive_mode': 'on',
|
||||||
'archive_command':
|
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
||||||
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
|
||||||
+ ' --mode archive '
|
+ ' --mode archive '
|
||||||
+ '--dirname {} --filename %f --pathname %p').format(
|
+ '--dirname {} --filename %f --pathname %p').format(
|
||||||
os.path.join(self._work_directory, 'data',
|
os.path.join(self._work_directory, 'data', 'wal_archive'))
|
||||||
f'wal_archive{str(self._citus_group or "")}')).replace('\\', '/'),
|
|
||||||
'restore_command':
|
|
||||||
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
|
|
||||||
+ ' --mode restore '
|
|
||||||
+ '--dirname {} --filename %f --pathname %p').format(
|
|
||||||
os.path.join(self._work_directory, 'data',
|
|
||||||
f'wal_archive{str(self._citus_group or "")}')).replace('\\', '/')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -936,6 +928,11 @@ class PatroniPoolController(object):
|
|||||||
custom_config = {
|
custom_config = {
|
||||||
'scope': cluster_name,
|
'scope': cluster_name,
|
||||||
'postgresql': {
|
'postgresql': {
|
||||||
|
'recovery_conf': {
|
||||||
|
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
|
||||||
|
+ '--dirname {} --filename %f --pathname %p')
|
||||||
|
.format(os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
|
||||||
|
},
|
||||||
'create_replica_methods': ['no_leader_bootstrap'],
|
'create_replica_methods': ['no_leader_bootstrap'],
|
||||||
'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
|
'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
Feature: nostream node
|
|
||||||
|
|
||||||
Scenario: check nostream node is recovering from archive
|
|
||||||
When I start postgres0
|
|
||||||
And I configure and start postgres1 with a tag nostream true
|
|
||||||
Then "members/postgres1" key in DCS has replication_state=in archive recovery after 10 seconds
|
|
||||||
And replication works from postgres0 to postgres1 after 30 seconds
|
|
||||||
|
|
||||||
@slot-advance
|
|
||||||
Scenario: check permanent logical replication slots are not copied
|
|
||||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}, "slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
|
|
||||||
Then I receive a response code 200
|
|
||||||
When I run patronictl.py restart batman postgres0 --force
|
|
||||||
Then postgres0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
|
|
||||||
When I configure and start postgres2 with a tag replicatefrom postgres1
|
|
||||||
Then "members/postgres2" key in DCS has replication_state=streaming after 10 seconds
|
|
||||||
And postgres1 does not have a replication slot named test_logical
|
|
||||||
And postgres2 does not have a replication slot named test_logical
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
Feature: quorum commit
|
|
||||||
Check basic workfrlows when quorum commit is enabled
|
|
||||||
|
|
||||||
Scenario: check enable quorum commit and that the only leader promotes after restart
|
|
||||||
Given I start postgres0
|
|
||||||
Then postgres0 is a leader after 10 seconds
|
|
||||||
And there is a non empty initialize key in DCS after 15 seconds
|
|
||||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"ttl": 20, "synchronous_mode": "quorum"}
|
|
||||||
Then I receive a response code 200
|
|
||||||
And sync key in DCS has leader=postgres0 after 20 seconds
|
|
||||||
And sync key in DCS has quorum=0 after 2 seconds
|
|
||||||
And synchronous_standby_names on postgres0 is set to "_empty_str_" after 2 seconds
|
|
||||||
When I shut down postgres0
|
|
||||||
And sync key in DCS has leader=postgres0 after 2 seconds
|
|
||||||
When I start postgres0
|
|
||||||
Then postgres0 role is the primary after 10 seconds
|
|
||||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"synchronous_mode_strict": true}
|
|
||||||
Then synchronous_standby_names on postgres0 is set to "ANY 1 (*)" after 10 seconds
|
|
||||||
|
|
||||||
Scenario: check failover with one quorum standby
|
|
||||||
Given I start postgres1
|
|
||||||
Then sync key in DCS has sync_standby=postgres1 after 10 seconds
|
|
||||||
And synchronous_standby_names on postgres0 is set to "ANY 1 (postgres1)" after 2 seconds
|
|
||||||
When I shut down postgres0
|
|
||||||
Then postgres1 role is the primary after 10 seconds
|
|
||||||
And sync key in DCS has quorum=0 after 10 seconds
|
|
||||||
Then synchronous_standby_names on postgres1 is set to "ANY 1 (*)" after 10 seconds
|
|
||||||
When I start postgres0
|
|
||||||
Then sync key in DCS has leader=postgres1 after 10 seconds
|
|
||||||
Then sync key in DCS has sync_standby=postgres0 after 10 seconds
|
|
||||||
And synchronous_standby_names on postgres1 is set to "ANY 1 (postgres0)" after 2 seconds
|
|
||||||
|
|
||||||
Scenario: check behavior with three nodes and different replication factor
|
|
||||||
Given I start postgres2
|
|
||||||
Then sync key in DCS has sync_standby=postgres0,postgres2 after 10 seconds
|
|
||||||
And sync key in DCS has quorum=1 after 2 seconds
|
|
||||||
And synchronous_standby_names on postgres1 is set to "ANY 1 (postgres0,postgres2)" after 2 seconds
|
|
||||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"synchronous_node_count": 2}
|
|
||||||
Then sync key in DCS has quorum=0 after 10 seconds
|
|
||||||
And synchronous_standby_names on postgres1 is set to "ANY 2 (postgres0,postgres2)" after 2 seconds
|
|
||||||
|
|
||||||
Scenario: switch from quorum replication to good old multisync and back
|
|
||||||
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"synchronous_mode": true, "synchronous_node_count": 1}
|
|
||||||
And I shut down postgres0
|
|
||||||
Then synchronous_standby_names on postgres1 is set to "postgres2" after 10 seconds
|
|
||||||
And sync key in DCS has sync_standby=postgres2 after 10 seconds
|
|
||||||
Then sync key in DCS has quorum=0 after 2 seconds
|
|
||||||
When I issue a PATCH request to http://127.0.0.1:8009/config with {"synchronous_mode": "quorum"}
|
|
||||||
And I start postgres0
|
|
||||||
Then synchronous_standby_names on postgres1 is set to "ANY 1 (postgres0,postgres2)" after 10 seconds
|
|
||||||
And sync key in DCS has sync_standby=postgres0,postgres2 after 10 seconds
|
|
||||||
Then sync key in DCS has quorum=1 after 2 seconds
|
|
||||||
|
|
||||||
Scenario: REST API and patronictl
|
|
||||||
Given I run patronictl.py list batman
|
|
||||||
Then I receive a response returncode 0
|
|
||||||
And I receive a response output "Quorum Standby"
|
|
||||||
And Status code on GET http://127.0.0.1:8008/quorum is 200 after 3 seconds
|
|
||||||
And Status code on GET http://127.0.0.1:8010/quorum is 200 after 3 seconds
|
|
||||||
|
|
||||||
Scenario: nosync node is removed from voters and synchronous_standby_names
|
|
||||||
Given I add tag nosync true to postgres2 config
|
|
||||||
When I issue an empty POST request to http://127.0.0.1:8010/reload
|
|
||||||
Then I receive a response code 202
|
|
||||||
And sync key in DCS has quorum=0 after 10 seconds
|
|
||||||
And sync key in DCS has sync_standby=postgres0 after 10 seconds
|
|
||||||
And synchronous_standby_names on postgres1 is set to "ANY 1 (postgres0)" after 2 seconds
|
|
||||||
And Status code on GET http://127.0.0.1:8010/quorum is 503 after 10 seconds
|
|
||||||
@@ -26,7 +26,7 @@ Feature: standby cluster
|
|||||||
Scenario: Detach exiting node from the cluster
|
Scenario: Detach exiting node from the cluster
|
||||||
When I shut down postgres1
|
When I shut down postgres1
|
||||||
Then postgres0 is a leader after 10 seconds
|
Then postgres0 is a leader after 10 seconds
|
||||||
And "members/postgres0" key in DCS has role=master after 5 seconds
|
And "members/postgres0" key in DCS has role=master after 3 seconds
|
||||||
When I issue a GET request to http://127.0.0.1:8008/
|
When I issue a GET request to http://127.0.0.1:8008/
|
||||||
Then I receive a response code 200
|
Then I receive a response code 200
|
||||||
|
|
||||||
@@ -47,7 +47,6 @@ Feature: standby cluster
|
|||||||
And there is a postgres1_cb.log with "on_role_change standby_leader batman1" in postgres1 data directory
|
And there is a postgres1_cb.log with "on_role_change standby_leader batman1" in postgres1 data directory
|
||||||
When I start postgres2 in a cluster batman1
|
When I start postgres2 in a cluster batman1
|
||||||
Then postgres2 role is the replica after 24 seconds
|
Then postgres2 role is the replica after 24 seconds
|
||||||
And postgres2 is replicating from postgres1 after 10 seconds
|
|
||||||
And table foo is present on postgres2 after 20 seconds
|
And table foo is present on postgres2 after 20 seconds
|
||||||
When I issue a GET request to http://127.0.0.1:8010/patroni
|
When I issue a GET request to http://127.0.0.1:8010/patroni
|
||||||
Then I receive a response code 200
|
Then I receive a response code 200
|
||||||
|
|||||||
@@ -46,17 +46,11 @@ def kill_postgres(context, name):
|
|||||||
return context.pctl.stop(name, kill=True, postgres=True)
|
return context.pctl.stop(name, kill=True, postgres=True)
|
||||||
|
|
||||||
|
|
||||||
def get_wal_name(context, pg_name):
|
|
||||||
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
|
|
||||||
return 'xlog' if int(version) / 10000 < 10 else 'wal'
|
|
||||||
|
|
||||||
|
|
||||||
@step('I add the table {table_name:w} to {pg_name:w}')
|
@step('I add the table {table_name:w} to {pg_name:w}')
|
||||||
def add_table(context, table_name, pg_name):
|
def add_table(context, table_name, pg_name):
|
||||||
# parse the configuration file and get the port
|
# parse the configuration file and get the port
|
||||||
try:
|
try:
|
||||||
context.pctl.query(pg_name, "CREATE TABLE public.{0}()".format(table_name))
|
context.pctl.query(pg_name, "CREATE TABLE public.{0}()".format(table_name))
|
||||||
context.pctl.query(pg_name, "SELECT pg_switch_{0}()".format(get_wal_name(context, pg_name)))
|
|
||||||
except pg.Error as e:
|
except pg.Error as e:
|
||||||
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
|
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
|
||||||
|
|
||||||
@@ -65,7 +59,9 @@ def add_table(context, table_name, pg_name):
|
|||||||
def toggle_wal_replay(context, action, pg_name):
|
def toggle_wal_replay(context, action, pg_name):
|
||||||
# pause or resume the wal replay process
|
# pause or resume the wal replay process
|
||||||
try:
|
try:
|
||||||
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(get_wal_name(context, pg_name), action))
|
version = context.pctl.query(pg_name, "SHOW server_version_num").fetchone()[0]
|
||||||
|
wal_name = 'xlog' if int(version) / 10000 < 10 else 'wal'
|
||||||
|
context.pctl.query(pg_name, "SELECT pg_{0}_replay_{1}()".format(wal_name, action))
|
||||||
except pg.Error as e:
|
except pg.Error as e:
|
||||||
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
|
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
|
|
||||||
from behave import step, then
|
|
||||||
|
|
||||||
|
|
||||||
@step('sync key in DCS has {key:w}={value} after {time_limit:d} seconds')
|
|
||||||
def check_sync(context, key, value, time_limit):
|
|
||||||
time_limit *= context.timeout_multiplier
|
|
||||||
max_time = time.time() + int(time_limit)
|
|
||||||
dcs_value = None
|
|
||||||
while time.time() < max_time:
|
|
||||||
try:
|
|
||||||
response = json.loads(context.dcs_ctl.query('sync'))
|
|
||||||
dcs_value = response.get(key)
|
|
||||||
if key == 'sync_standby' and set((dcs_value or '').split(',')) == set(value.split(',')):
|
|
||||||
return
|
|
||||||
elif str(dcs_value) == value:
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(1)
|
|
||||||
assert False, "sync does not have {0}={1} (found {2}) in dcs after {3} seconds".format(key, value,
|
|
||||||
dcs_value, time_limit)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_synchronous_standby_names(value):
|
|
||||||
if '(' in value:
|
|
||||||
m = re.match(r'.*(\d+) \(([^)]+)\)', value)
|
|
||||||
expected_value = set(m.group(2).split())
|
|
||||||
expected_num = m.group(1)
|
|
||||||
else:
|
|
||||||
expected_value = set([value])
|
|
||||||
expected_num = '1'
|
|
||||||
return expected_num, expected_value
|
|
||||||
|
|
||||||
|
|
||||||
@then('synchronous_standby_names on {name:2} is set to "{value}" after {time_limit:d} seconds')
|
|
||||||
def check_synchronous_standby_names(context, name, value, time_limit):
|
|
||||||
time_limit *= context.timeout_multiplier
|
|
||||||
max_time = time.time() + int(time_limit)
|
|
||||||
|
|
||||||
if value == '_empty_str_':
|
|
||||||
value = ''
|
|
||||||
|
|
||||||
expected_num, expected_value = _parse_synchronous_standby_names(value)
|
|
||||||
|
|
||||||
ssn = None
|
|
||||||
while time.time() < max_time:
|
|
||||||
try:
|
|
||||||
ssn = context.pctl.query(name, "SHOW synchronous_standby_names").fetchone()[0]
|
|
||||||
db_num, db_value = _parse_synchronous_standby_names(ssn)
|
|
||||||
if expected_value == db_value and expected_num == db_num:
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(1)
|
|
||||||
assert False, "synchronous_standby_names is not set to '{0}' (found '{1}') after {2} seconds".format(value, ssn,
|
|
||||||
time_limit)
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM postgres:16
|
FROM postgres:15
|
||||||
LABEL maintainer="Alexander Kukushkin <[email protected]>"
|
LABEL maintainer="Alexander Kukushkin <[email protected]>"
|
||||||
|
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM postgres:16
|
FROM postgres:15
|
||||||
LABEL maintainer="Alexander Kukushkin <[email protected]>"
|
LABEL maintainer="Alexander Kukushkin <[email protected]>"
|
||||||
|
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
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
|
## 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 \
|
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||||
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
||||||
apt-get install -y postgresql-server-dev-16 \
|
apt-get install -y postgresql-server-dev-15 \
|
||||||
gcc make autoconf \
|
gcc make autoconf \
|
||||||
libc6-dev flex libcurl4-gnutls-dev \
|
libc6-dev flex libcurl4-gnutls-dev \
|
||||||
libicu-dev libkrb5-dev liblz4-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 \
|
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 \
|
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||||
&& apt-get update -y \
|
&& apt-get update -y \
|
||||||
&& apt-get -y install postgresql-16-citus-12.1; \
|
&& apt-get -y install postgresql-15-citus-12.0; \
|
||||||
fi \
|
fi \
|
||||||
&& pip3 install --break-system-packages setuptools \
|
&& pip3 install --break-system-packages setuptools \
|
||||||
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
&& 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 \
|
&& chmod 664 /etc/passwd \
|
||||||
# Clean up
|
# Clean up
|
||||||
&& apt-get remove -y git python3-pip python3-wheel \
|
&& apt-get remove -y git python3-pip python3-wheel \
|
||||||
postgresql-server-dev-16 gcc make autoconf \
|
postgresql-server-dev-15 gcc make autoconf \
|
||||||
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
|
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
|
||||||
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ class Patroni(AbstractPatroniDaemon, Tags):
|
|||||||
self.watchdog = Watchdog(self.config)
|
self.watchdog = Watchdog(self.config)
|
||||||
self.load_dynamic_configuration()
|
self.load_dynamic_configuration()
|
||||||
|
|
||||||
self.postgresql = Postgresql(self.config['postgresql'], self.dcs.mpp)
|
self.postgresql = Postgresql(self.config['postgresql'])
|
||||||
self.api = RestApiServer(self, self.config['restapi'])
|
self.api = RestApiServer(self, self.config['restapi'])
|
||||||
self.ha = Ha(self)
|
self.ha = Ha(self)
|
||||||
|
|
||||||
|
|||||||
+26
-59
@@ -26,7 +26,7 @@ from urllib.parse import urlparse, parse_qs
|
|||||||
|
|
||||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union
|
||||||
|
|
||||||
from . import global_config, psycopg
|
from . import psycopg
|
||||||
from .__main__ import Patroni
|
from .__main__ import Patroni
|
||||||
from .dcs import Cluster
|
from .dcs import Cluster
|
||||||
from .exceptions import PostgresConnectionException, PostgresException
|
from .exceptions import PostgresConnectionException, PostgresException
|
||||||
@@ -180,8 +180,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
* ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
|
* ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
|
||||||
* ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
|
* ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
|
||||||
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
|
* ``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``: a dictionary with a single key ``schedule``, which is the timestamp for the
|
||||||
scheduled restart;
|
scheduled restart;
|
||||||
* ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
|
* ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
|
||||||
@@ -198,9 +196,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
response['tags'] = tags
|
response['tags'] = tags
|
||||||
if patroni.postgresql.sysid:
|
if patroni.postgresql.sysid:
|
||||||
response['database_system_identifier'] = patroni.postgresql.sysid
|
response['database_system_identifier'] = patroni.postgresql.sysid
|
||||||
if patroni.postgresql.pending_restart_reason:
|
if patroni.postgresql.pending_restart:
|
||||||
response['pending_restart'] = True
|
response['pending_restart'] = True
|
||||||
response['pending_restart_reason'] = dict(patroni.postgresql.pending_restart_reason)
|
|
||||||
response['patroni'] = {
|
response['patroni'] = {
|
||||||
'version': patroni.version,
|
'version': patroni.version,
|
||||||
'scope': patroni.postgresql.scope,
|
'scope': patroni.postgresql.scope,
|
||||||
@@ -254,14 +251,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
* HTTP status ``200``: if up and running and without ``noloadbalance`` tag.
|
* HTTP status ``200``: if up and running and without ``noloadbalance`` tag.
|
||||||
|
|
||||||
* ``/quorum``:
|
|
||||||
|
|
||||||
* HTTP status ``200``: if up and running as a quorum synchronous standby.
|
|
||||||
|
|
||||||
* ``/read-only-quorum``:
|
|
||||||
|
|
||||||
* HTTP status ``200``: if up and running as a quorum synchronous standby or primary.
|
|
||||||
|
|
||||||
* ``/synchronous`` or ``/sync``:
|
* ``/synchronous`` or ``/sync``:
|
||||||
|
|
||||||
* HTTP status ``200``: if up and running as a synchronous standby.
|
* HTTP status ``200``: if up and running as a synchronous standby.
|
||||||
@@ -301,7 +290,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
patroni = self.server.patroni
|
patroni = self.server.patroni
|
||||||
cluster = patroni.dcs.cluster
|
cluster = patroni.dcs.cluster
|
||||||
config = global_config.from_cluster(cluster)
|
global_config = patroni.config.get_global_config(cluster)
|
||||||
|
|
||||||
leader_optime = cluster and cluster.last_lsn or 0
|
leader_optime = cluster and cluster.last_lsn or 0
|
||||||
replayed_location = response.get('xlog', {}).get('replayed_location', 0)
|
replayed_location = response.get('xlog', {}).get('replayed_location', 0)
|
||||||
@@ -319,7 +308,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503
|
standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503
|
||||||
elif patroni.ha.is_leader():
|
elif patroni.ha.is_leader():
|
||||||
leader_status_code = 200
|
leader_status_code = 200
|
||||||
if config.is_standby_cluster:
|
if global_config.is_standby_cluster:
|
||||||
primary_status_code = replica_status_code = 503
|
primary_status_code = replica_status_code = 503
|
||||||
standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503
|
standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503
|
||||||
else:
|
else:
|
||||||
@@ -342,23 +331,15 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
ignore_tags = True
|
ignore_tags = True
|
||||||
elif 'replica' in path:
|
elif 'replica' in path:
|
||||||
status_code = replica_status_code
|
status_code = replica_status_code
|
||||||
elif 'read-only' in path and 'sync' not in path and 'quorum' not in path:
|
elif 'read-only' in path and 'sync' not in path:
|
||||||
status_code = 200 if 200 in (primary_status_code, standby_leader_status_code) else replica_status_code
|
status_code = 200 if 200 in (primary_status_code, standby_leader_status_code) else replica_status_code
|
||||||
elif 'health' in path:
|
elif 'health' in path:
|
||||||
status_code = 200 if response.get('state') == 'running' else 503
|
status_code = 200 if response.get('state') == 'running' else 503
|
||||||
elif cluster: # dcs is available
|
elif cluster: # dcs is available
|
||||||
is_quorum = response.get('quorum_standby')
|
|
||||||
is_synchronous = response.get('sync_standby')
|
is_synchronous = response.get('sync_standby')
|
||||||
if path in ('/sync', '/synchronous') and is_synchronous:
|
if path in ('/sync', '/synchronous') and is_synchronous:
|
||||||
status_code = replica_status_code
|
status_code = replica_status_code
|
||||||
elif path == '/quorum' and is_quorum:
|
elif path in ('/async', '/asynchronous') and not is_synchronous:
|
||||||
status_code = replica_status_code
|
|
||||||
elif path in ('/async', '/asynchronous') and not is_synchronous and not is_quorum:
|
|
||||||
status_code = replica_status_code
|
|
||||||
elif path == '/read-only-quorum':
|
|
||||||
if 200 in (primary_status_code, standby_leader_status_code):
|
|
||||||
status_code = 200
|
|
||||||
elif is_quorum:
|
|
||||||
status_code = replica_status_code
|
status_code = replica_status_code
|
||||||
elif path in ('/read-only-sync', '/read-only-synchronous'):
|
elif path in ('/read-only-sync', '/read-only-synchronous'):
|
||||||
if 200 in (primary_status_code, standby_leader_status_code):
|
if 200 in (primary_status_code, standby_leader_status_code):
|
||||||
@@ -471,8 +452,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
HTTP status ``200`` and the JSON representation of the cluster topology.
|
HTTP status ``200`` and the JSON representation of the cluster topology.
|
||||||
"""
|
"""
|
||||||
cluster = self.server.patroni.dcs.get_cluster()
|
cluster = self.server.patroni.dcs.get_cluster()
|
||||||
|
global_config = self.server.patroni.config.get_global_config(cluster)
|
||||||
|
|
||||||
response = cluster_as_json(cluster)
|
response = cluster_as_json(cluster, global_config)
|
||||||
response['scope'] = self.server.patroni.postgresql.scope
|
response['scope'] = self.server.patroni.postgresql.scope
|
||||||
self._write_json_response(200, response)
|
self._write_json_response(200, response)
|
||||||
|
|
||||||
@@ -526,7 +508,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
* ``patroni_standby_leader``: ``1`` if standby leader node, else ``0``;
|
* ``patroni_standby_leader``: ``1`` if standby leader node, else ``0``;
|
||||||
* ``patroni_replica``: ``1`` if a replica, else ``0``;
|
* ``patroni_replica``: ``1`` if a replica, else ``0``;
|
||||||
* ``patroni_sync_standby``: ``1`` if a sync replica, else ``0``;
|
* ``patroni_sync_standby``: ``1`` if a sync replica, else ``0``;
|
||||||
* ``patroni_quorum_standby``: ``1`` if a quorum sync replica, else ``0``;
|
|
||||||
* ``patroni_xlog_received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
|
* ``patroni_xlog_received_location``: ``pg_wal_lsn_diff(pg_last_wal_receive_lsn(), '0/0')``;
|
||||||
* ``patroni_xlog_replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
|
* ``patroni_xlog_replayed_location``: ``pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '0/0)``;
|
||||||
* ``patroni_xlog_replayed_timestamp``: ``pg_last_xact_replay_timestamp``;
|
* ``patroni_xlog_replayed_timestamp``: ``pg_last_xact_replay_timestamp``;
|
||||||
@@ -589,14 +570,10 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
metrics.append("# TYPE patroni_replica gauge")
|
metrics.append("# TYPE patroni_replica gauge")
|
||||||
metrics.append("patroni_replica{0} {1}".format(labels, int(postgres['role'] == 'replica')))
|
metrics.append("patroni_replica{0} {1}".format(labels, int(postgres['role'] == 'replica')))
|
||||||
|
|
||||||
metrics.append("# HELP patroni_sync_standby Value is 1 if this node is a sync standby, 0 otherwise.")
|
metrics.append("# HELP patroni_sync_standby Value is 1 if this node is a sync standby replica, 0 otherwise.")
|
||||||
metrics.append("# TYPE patroni_sync_standby gauge")
|
metrics.append("# TYPE patroni_sync_standby gauge")
|
||||||
metrics.append("patroni_sync_standby{0} {1}".format(labels, int(postgres.get('sync_standby', False))))
|
metrics.append("patroni_sync_standby{0} {1}".format(labels, int(postgres.get('sync_standby', False))))
|
||||||
|
|
||||||
metrics.append("# HELP patroni_quorum_standby Value is 1 if this node is a quorum standby, 0 otherwise.")
|
|
||||||
metrics.append("# TYPE patroni_quorum_standby gauge")
|
|
||||||
metrics.append("patroni_quorum_standby{0} {1}".format(labels, int(postgres.get('quorum_standby', False))))
|
|
||||||
|
|
||||||
metrics.append("# HELP patroni_xlog_received_location Current location of the received"
|
metrics.append("# HELP patroni_xlog_received_location Current location of the received"
|
||||||
" Postgres transaction log, 0 if this node is not a replica.")
|
" Postgres transaction log, 0 if this node is not a replica.")
|
||||||
metrics.append("# TYPE patroni_xlog_received_location counter")
|
metrics.append("# TYPE patroni_xlog_received_location counter")
|
||||||
@@ -658,7 +635,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
|
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("# TYPE patroni_pending_restart gauge")
|
||||||
metrics.append("patroni_pending_restart{0} {1}"
|
metrics.append("patroni_pending_restart{0} {1}"
|
||||||
.format(labels, int(bool(patroni.postgresql.pending_restart_reason))))
|
.format(labels, int(patroni.postgresql.pending_restart)))
|
||||||
|
|
||||||
metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
|
metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
|
||||||
metrics.append("# TYPE patroni_is_paused gauge")
|
metrics.append("# TYPE patroni_is_paused gauge")
|
||||||
@@ -887,7 +864,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
if request:
|
if request:
|
||||||
logger.debug("received restart request: {0}".format(request))
|
logger.debug("received restart request: {0}".format(request))
|
||||||
|
|
||||||
if global_config.from_cluster(cluster).is_paused and 'schedule' in request:
|
if self.server.patroni.config.get_global_config(cluster).is_paused and 'schedule' in request:
|
||||||
self.write_response(status_code, "Can't schedule restart in the paused state")
|
self.write_response(status_code, "Can't schedule restart in the paused state")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1056,17 +1033,16 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
:returns: a string with the error message or ``None`` if good nodes are found.
|
:returns: a string with the error message or ``None`` if good nodes are found.
|
||||||
"""
|
"""
|
||||||
config = global_config.from_cluster(cluster)
|
is_synchronous_mode = self.server.patroni.config.get_global_config(cluster).is_synchronous_mode
|
||||||
if leader and (not cluster.leader or cluster.leader.name != leader):
|
if leader and (not cluster.leader or cluster.leader.name != leader):
|
||||||
return 'leader name does not match'
|
return 'leader name does not match'
|
||||||
if candidate:
|
if candidate:
|
||||||
if action == 'switchover' and config.is_synchronous_mode\
|
if action == 'switchover' and is_synchronous_mode and not cluster.sync.matches(candidate):
|
||||||
and not config.is_quorum_commit_mode and not cluster.sync.matches(candidate):
|
|
||||||
return 'candidate name does not match with sync_standby'
|
return 'candidate name does not match with sync_standby'
|
||||||
members = [m for m in cluster.members if m.name == candidate]
|
members = [m for m in cluster.members if m.name == candidate]
|
||||||
if not members:
|
if not members:
|
||||||
return 'candidate does not exists'
|
return 'candidate does not exists'
|
||||||
elif config.is_synchronous_mode and not config.is_quorum_commit_mode:
|
elif is_synchronous_mode:
|
||||||
members = [m for m in cluster.members if cluster.sync.matches(m.name)]
|
members = [m for m in cluster.members if cluster.sync.matches(m.name)]
|
||||||
if not members:
|
if not members:
|
||||||
return action + ' is not possible: can not find sync_standby'
|
return action + ' is not possible: can not find sync_standby'
|
||||||
@@ -1115,7 +1091,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
candidate = request.get('candidate') or request.get('member')
|
candidate = request.get('candidate') or request.get('member')
|
||||||
scheduled_at = request.get('scheduled_at')
|
scheduled_at = request.get('scheduled_at')
|
||||||
cluster = self.server.patroni.dcs.get_cluster()
|
cluster = self.server.patroni.dcs.get_cluster()
|
||||||
config = global_config.from_cluster(cluster)
|
global_config = self.server.patroni.config.get_global_config(cluster)
|
||||||
|
|
||||||
logger.info("received %s request with leader=%s candidate=%s scheduled_at=%s",
|
logger.info("received %s request with leader=%s candidate=%s scheduled_at=%s",
|
||||||
action, leader, candidate, scheduled_at)
|
action, leader, candidate, scheduled_at)
|
||||||
@@ -1128,12 +1104,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
if not data and scheduled_at:
|
if not data and scheduled_at:
|
||||||
if action == 'failover':
|
if action == 'failover':
|
||||||
data = "Failover can't be scheduled"
|
data = "Failover can't be scheduled"
|
||||||
elif config.is_paused:
|
elif global_config.is_paused:
|
||||||
data = "Can't schedule switchover in the paused state"
|
data = "Can't schedule switchover in the paused state"
|
||||||
else:
|
else:
|
||||||
(status_code, data, scheduled_at) = self.parse_schedule(scheduled_at, action)
|
(status_code, data, scheduled_at) = self.parse_schedule(scheduled_at, action)
|
||||||
|
|
||||||
if not data and config.is_paused and not candidate:
|
if not data and global_config.is_paused and not candidate:
|
||||||
data = 'Switchover is possible only to a specific candidate in a paused state'
|
data = 'Switchover is possible only to a specific candidate in a paused state'
|
||||||
|
|
||||||
if action == 'failover' and leader:
|
if action == 'failover' and leader:
|
||||||
@@ -1178,16 +1154,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
def do_POST_citus(self) -> None:
|
def do_POST_citus(self) -> None:
|
||||||
"""Handle a ``POST`` request to ``/citus`` path.
|
"""Handle a ``POST`` request to ``/citus`` path.
|
||||||
|
|
||||||
.. note::
|
Call :func:`~patroni.postgresql.CitusHandler.handle_event` to handle the request, then write a response with
|
||||||
We keep this entrypoint for backward compatibility and simply dispatch the request to :meth:`do_POST_mpp`.
|
HTTP status code ``200``.
|
||||||
"""
|
|
||||||
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::
|
.. note::
|
||||||
If unable to parse the request body, then the request is silently discarded.
|
If unable to parse the request body, then the request is silently discarded.
|
||||||
@@ -1197,9 +1165,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
patroni = self.server.patroni
|
patroni = self.server.patroni
|
||||||
if patroni.postgresql.mpp_handler.is_coordinator() and patroni.ha.is_leader():
|
if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader():
|
||||||
cluster = patroni.dcs.get_cluster()
|
cluster = patroni.dcs.get_cluster()
|
||||||
patroni.postgresql.mpp_handler.handle_event(cluster, request)
|
patroni.postgresql.citus_handler.handle_event(cluster, request)
|
||||||
self.write_response(200, 'OK')
|
self.write_response(200, 'OK')
|
||||||
|
|
||||||
def parse_request(self) -> bool:
|
def parse_request(self) -> bool:
|
||||||
@@ -1273,7 +1241,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
* ``paused``: ``pg_is_wal_replay_paused()``;
|
* ``paused``: ``pg_is_wal_replay_paused()``;
|
||||||
|
|
||||||
* ``sync_standby``: ``True`` if replication mode is synchronous and this is a sync standby;
|
* ``sync_standby``: ``True`` if replication mode is synchronous and this is a sync standby;
|
||||||
* ``quorum_standby``: ``True`` if replication mode is quorum and this is a quorum standby;
|
|
||||||
* ``timeline``: PostgreSQL primary node timeline;
|
* ``timeline``: PostgreSQL primary node timeline;
|
||||||
* ``replication``: :class:`list` of :class:`dict` entries, one for each replication connection. Each entry
|
* ``replication``: :class:`list` of :class:`dict` entries, one for each replication connection. Each entry
|
||||||
contains the following keys:
|
contains the following keys:
|
||||||
@@ -1293,7 +1260,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
"""
|
"""
|
||||||
postgresql = self.server.patroni.postgresql
|
postgresql = self.server.patroni.postgresql
|
||||||
cluster = self.server.patroni.dcs.cluster
|
cluster = self.server.patroni.dcs.cluster
|
||||||
config = global_config.from_cluster(cluster)
|
global_config = self.server.patroni.config.get_global_config(cluster)
|
||||||
try:
|
try:
|
||||||
|
|
||||||
if postgresql.state not in ('running', 'restarting', 'starting'):
|
if postgresql.state not in ('running', 'restarting', 'starting'):
|
||||||
@@ -1324,12 +1291,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if result['role'] == 'replica' and config.is_standby_cluster:
|
if result['role'] == 'replica' and global_config.is_standby_cluster:
|
||||||
result['role'] = postgresql.role
|
result['role'] = postgresql.role
|
||||||
|
|
||||||
if result['role'] == 'replica' and config.is_synchronous_mode\
|
if result['role'] == 'replica' and global_config.is_synchronous_mode\
|
||||||
and cluster and cluster.sync.matches(postgresql.name):
|
and cluster and cluster.sync.matches(postgresql.name):
|
||||||
result['quorum_standby' if global_config.is_quorum_commit_mode else 'sync_standby'] = True
|
result['sync_standby'] = True
|
||||||
|
|
||||||
if row[1] > 0:
|
if row[1] > 0:
|
||||||
result['timeline'] = row[1]
|
result['timeline'] = row[1]
|
||||||
@@ -1352,7 +1319,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
|||||||
state = 'unknown'
|
state = 'unknown'
|
||||||
result: Dict[str, Any] = {'state': state, 'role': postgresql.role}
|
result: Dict[str, Any] = {'state': state, 'role': postgresql.role}
|
||||||
|
|
||||||
if config.is_paused:
|
if global_config.is_paused:
|
||||||
result['pause'] = True
|
result['pause'] = True
|
||||||
if not cluster or cluster.is_unlocked():
|
if not cluster or cluster.is_unlocked():
|
||||||
result['cluster_unlocked'] = True
|
result['cluster_unlocked'] = True
|
||||||
|
|||||||
+167
-19
@@ -1,5 +1,4 @@
|
|||||||
"""Facilities related to Patroni configuration."""
|
"""Facilities related to Patroni configuration."""
|
||||||
import re
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -13,7 +12,7 @@ from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_
|
|||||||
|
|
||||||
from . import PATRONI_ENV_PREFIX
|
from . import PATRONI_ENV_PREFIX
|
||||||
from .collections import CaseInsensitiveDict
|
from .collections import CaseInsensitiveDict
|
||||||
from .dcs import ClusterConfig
|
from .dcs import ClusterConfig, Cluster
|
||||||
from .exceptions import ConfigParseError
|
from .exceptions import ConfigParseError
|
||||||
from .file_perm import pg_perm
|
from .file_perm import pg_perm
|
||||||
from .postgresql.config import ConfigHandler
|
from .postgresql.config import ConfigHandler
|
||||||
@@ -55,6 +54,154 @@ def default_validator(conf: Dict[str, Any]) -> List[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalConfig(object):
|
||||||
|
"""A class that wraps global configuration and provides convenient methods to access/check values.
|
||||||
|
|
||||||
|
It is instantiated either by calling :func:`get_global_config` or :meth:`Config.get_global_config`, which picks
|
||||||
|
either a configuration from provided :class:`Cluster` object (the most up-to-date) or from the
|
||||||
|
local cache if :class:`ClusterConfig` is not initialized or doesn't have a valid config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
|
"""Initialize :class:`GlobalConfig` object with given *config*.
|
||||||
|
|
||||||
|
:param config: current configuration either from
|
||||||
|
:class:`ClusterConfig` or from :func:`Config.dynamic_configuration`.
|
||||||
|
"""
|
||||||
|
self.__config = config
|
||||||
|
|
||||||
|
def get(self, name: str) -> Any:
|
||||||
|
"""Gets global configuration value by *name*.
|
||||||
|
|
||||||
|
:param name: parameter name.
|
||||||
|
|
||||||
|
:returns: configuration value or ``None`` if it is missing.
|
||||||
|
"""
|
||||||
|
return self.__config.get(name)
|
||||||
|
|
||||||
|
def check_mode(self, mode: str) -> bool:
|
||||||
|
"""Checks whether the certain parameter is enabled.
|
||||||
|
|
||||||
|
:param mode: parameter name, e.g. ``synchronous_mode``, ``failsafe_mode``, ``pause``, ``check_timeline``, and
|
||||||
|
so on.
|
||||||
|
|
||||||
|
:returns: ``True`` if parameter *mode* is enabled in the global configuration.
|
||||||
|
"""
|
||||||
|
return bool(parse_bool(self.__config.get(mode)))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paused(self) -> bool:
|
||||||
|
"""``True`` if cluster is in maintenance mode."""
|
||||||
|
return self.check_mode('pause')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_synchronous_mode(self) -> bool:
|
||||||
|
"""``True`` if synchronous replication is requested and it is not a standby cluster config."""
|
||||||
|
return self.check_mode('synchronous_mode') and not self.is_standby_cluster
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_synchronous_mode_strict(self) -> bool:
|
||||||
|
"""``True`` if at least one synchronous node is required."""
|
||||||
|
return self.check_mode('synchronous_mode_strict')
|
||||||
|
|
||||||
|
def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]:
|
||||||
|
"""Get ``standby_cluster`` configuration.
|
||||||
|
|
||||||
|
:returns: a copy of ``standby_cluster`` configuration.
|
||||||
|
"""
|
||||||
|
return deepcopy(self.get('standby_cluster'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_standby_cluster(self) -> bool:
|
||||||
|
"""``True`` if global configuration has a valid ``standby_cluster`` section."""
|
||||||
|
config = self.get_standby_cluster_config()
|
||||||
|
return isinstance(config, dict) and\
|
||||||
|
bool(config.get('host') or config.get('port') or config.get('restore_command'))
|
||||||
|
|
||||||
|
def get_int(self, name: str, default: int = 0) -> int:
|
||||||
|
"""Gets current value of *name* from the global configuration and try to return it as :class:`int`.
|
||||||
|
|
||||||
|
:param name: name of the parameter.
|
||||||
|
:param default: default value if *name* is not in the configuration or invalid.
|
||||||
|
|
||||||
|
:returns: currently configured value of *name* from the global configuration or *default* if it is not set or
|
||||||
|
invalid.
|
||||||
|
"""
|
||||||
|
ret = parse_int(self.get(name))
|
||||||
|
return default if ret is None else ret
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_synchronous_nodes(self) -> int:
|
||||||
|
"""The minimal number of synchronous nodes based on whether ``synchronous_mode_strict`` is enabled or not."""
|
||||||
|
return 1 if self.is_synchronous_mode_strict else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def synchronous_node_count(self) -> int:
|
||||||
|
"""Currently configured value of ``synchronous_node_count`` from the global configuration.
|
||||||
|
|
||||||
|
Assume ``1`` if it is not set or invalid.
|
||||||
|
"""
|
||||||
|
return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maximum_lag_on_failover(self) -> int:
|
||||||
|
"""Currently configured value of ``maximum_lag_on_failover`` from the global configuration.
|
||||||
|
|
||||||
|
Assume ``1048576`` if it is not set or invalid.
|
||||||
|
"""
|
||||||
|
return self.get_int('maximum_lag_on_failover', 1048576)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maximum_lag_on_syncnode(self) -> int:
|
||||||
|
"""Currently configured value of ``maximum_lag_on_syncnode`` from the global configuration.
|
||||||
|
|
||||||
|
Assume ``-1`` if it is not set or invalid.
|
||||||
|
"""
|
||||||
|
return self.get_int('maximum_lag_on_syncnode', -1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_start_timeout(self) -> int:
|
||||||
|
"""Currently configured value of ``primary_start_timeout`` from the global configuration.
|
||||||
|
|
||||||
|
Assume ``300`` if it is not set or invalid.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
``master_start_timeout`` is still supported to keep backward compatibility.
|
||||||
|
"""
|
||||||
|
default = 300
|
||||||
|
return self.get_int('primary_start_timeout', default)\
|
||||||
|
if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_stop_timeout(self) -> int:
|
||||||
|
"""Currently configured value of ``primary_stop_timeout`` from the global configuration.
|
||||||
|
|
||||||
|
Assume ``0`` if it is not set or invalid.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
``master_stop_timeout`` is still supported to keep backward compatibility.
|
||||||
|
"""
|
||||||
|
default = 0
|
||||||
|
return self.get_int('primary_stop_timeout', default)\
|
||||||
|
if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default)
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_config(cluster: Optional[Cluster], default: Optional[Dict[str, Any]] = None) -> GlobalConfig:
|
||||||
|
"""Instantiates :class:`GlobalConfig` based on the input.
|
||||||
|
|
||||||
|
:param cluster: the currently known cluster state from DCS.
|
||||||
|
:param default: default configuration, which will be used if there is no valid *cluster.config*.
|
||||||
|
|
||||||
|
:returns: :class:`GlobalConfig` object.
|
||||||
|
"""
|
||||||
|
# Try to protect from the case when DCS was wiped out
|
||||||
|
if cluster and cluster.config and cluster.config.modify_version:
|
||||||
|
config = cluster.config.data
|
||||||
|
else:
|
||||||
|
config = default or {}
|
||||||
|
return GlobalConfig(deepcopy(config))
|
||||||
|
|
||||||
|
|
||||||
class Config(object):
|
class Config(object):
|
||||||
"""Handle Patroni configuration.
|
"""Handle Patroni configuration.
|
||||||
|
|
||||||
@@ -535,8 +682,8 @@ class Config(object):
|
|||||||
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
|
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
|
||||||
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
|
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
|
||||||
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
|
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
|
||||||
_set_section_values('log', ['type', 'level', 'traceback_level', 'format', 'dateformat', 'static_fields',
|
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
|
||||||
'max_queue_size', 'dir', 'file_size', 'file_num', 'loggers'])
|
'dir', 'file_size', 'file_num', 'loggers'])
|
||||||
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
|
_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'):
|
for binary in ('pg_ctl', 'initdb', 'pg_controldata', 'pg_basebackup', 'postgres', 'pg_isready', 'pg_rewind'):
|
||||||
@@ -583,12 +730,6 @@ class Config(object):
|
|||||||
if value:
|
if value:
|
||||||
ret[first][second] = 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]]:
|
def _parse_dict(value: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Parse an YAML dictionary *value* as a :class:`dict`.
|
"""Parse an YAML dictionary *value* as a :class:`dict`.
|
||||||
|
|
||||||
@@ -604,12 +745,7 @@ class Config(object):
|
|||||||
logger.exception('Exception when parsing dict %s', value)
|
logger.exception('Exception when parsing dict %s', value)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
dict_configs = (
|
for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
|
||||||
('restapi', ('http_extra_headers', 'https_extra_headers')),
|
|
||||||
('log', ('static_fields', 'loggers'))
|
|
||||||
)
|
|
||||||
|
|
||||||
for first, params in dict_configs:
|
|
||||||
for second in params:
|
for second in params:
|
||||||
value = ret.get(first, {}).pop(second, None)
|
value = ret.get(first, {}).pop(second, None)
|
||||||
if value:
|
if value:
|
||||||
@@ -656,7 +792,7 @@ class Config(object):
|
|||||||
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
|
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
|
||||||
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
|
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
|
||||||
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
|
'USE_SSL', 'SET_ACLS', 'GROUP', 'DATABASE', 'LEADER_LABEL_VALUE', 'FOLLOWER_LABEL_VALUE',
|
||||||
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL', 'AUTH_DATA') and name:
|
'STANDBY_LEADER_LABEL_VALUE', 'TMP_ROLE_LABEL') and name:
|
||||||
value = os.environ.pop(param)
|
value = os.environ.pop(param)
|
||||||
if name == 'CITUS':
|
if name == 'CITUS':
|
||||||
if suffix == 'GROUP':
|
if suffix == 'GROUP':
|
||||||
@@ -667,7 +803,7 @@ class Config(object):
|
|||||||
value = value and parse_int(value)
|
value = value and parse_int(value)
|
||||||
elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'):
|
elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'):
|
||||||
value = value and _parse_list(value)
|
value = value and _parse_list(value)
|
||||||
elif suffix in ('LABELS', 'SET_ACLS', 'AUTH_DATA'):
|
elif suffix in ('LABELS', 'SET_ACLS'):
|
||||||
value = _parse_dict(value)
|
value = _parse_dict(value)
|
||||||
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
|
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
|
||||||
value = parse_bool(value)
|
value = parse_bool(value)
|
||||||
@@ -757,7 +893,7 @@ class Config(object):
|
|||||||
if 'citus' in config:
|
if 'citus' in config:
|
||||||
bootstrap = config.setdefault('bootstrap', {})
|
bootstrap = config.setdefault('bootstrap', {})
|
||||||
dcs = bootstrap.setdefault('dcs', {})
|
dcs = bootstrap.setdefault('dcs', {})
|
||||||
dcs.setdefault('synchronous_mode', 'quorum')
|
dcs.setdefault('synchronous_mode', True)
|
||||||
|
|
||||||
updated_fields = (
|
updated_fields = (
|
||||||
'name',
|
'name',
|
||||||
@@ -814,6 +950,18 @@ class Config(object):
|
|||||||
"""
|
"""
|
||||||
return deepcopy(self.__effective_configuration)
|
return deepcopy(self.__effective_configuration)
|
||||||
|
|
||||||
|
def get_global_config(self, cluster: Optional[Cluster]) -> GlobalConfig:
|
||||||
|
"""Instantiate :class:`GlobalConfig` based on input.
|
||||||
|
|
||||||
|
Use the configuration from provided *cluster* (the most up-to-date) or from the
|
||||||
|
local cache if *cluster.config* is not initialized or doesn't have a valid config.
|
||||||
|
|
||||||
|
:param cluster: the currently known cluster state from DCS.
|
||||||
|
|
||||||
|
:returns: :class:`GlobalConfig` object.
|
||||||
|
"""
|
||||||
|
return get_global_config(cluster, self._dynamic_configuration)
|
||||||
|
|
||||||
def _validate_failover_tags(self) -> None:
|
def _validate_failover_tags(self) -> None:
|
||||||
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
|
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ class AbstractConfigGenerator(abc.ABC):
|
|||||||
'listen': cls._IP + ':8008'
|
'listen': cls._IP + ':8008'
|
||||||
},
|
},
|
||||||
'log': {
|
'log': {
|
||||||
'type': PatroniLogger.DEFAULT_TYPE,
|
|
||||||
'level': PatroniLogger.DEFAULT_LEVEL,
|
'level': PatroniLogger.DEFAULT_LEVEL,
|
||||||
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
||||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||||
@@ -126,7 +125,6 @@ class AbstractConfigGenerator(abc.ABC):
|
|||||||
'noloadbalance': False,
|
'noloadbalance': False,
|
||||||
'clonefrom': True,
|
'clonefrom': True,
|
||||||
'nosync': False,
|
'nosync': False,
|
||||||
'nostream': False,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+149
-136
@@ -41,21 +41,15 @@ if TYPE_CHECKING: # pragma: no cover
|
|||||||
from psycopg import Cursor
|
from psycopg import Cursor
|
||||||
from psycopg2 import cursor
|
from psycopg2 import cursor
|
||||||
|
|
||||||
try: # pragma: no cover
|
|
||||||
from ydiff import markup_to_pager # pyright: ignore [reportMissingModuleSource]
|
|
||||||
try:
|
try:
|
||||||
from ydiff import PatchStream # pyright: ignore [reportMissingModuleSource]
|
from ydiff import markup_to_pager, PatchStream # pyright: ignore [reportMissingModuleSource]
|
||||||
except ImportError:
|
|
||||||
PatchStream = iter
|
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
from cdiff import markup_to_pager, PatchStream # pyright: ignore [reportMissingModuleSource]
|
from cdiff import markup_to_pager, PatchStream # pyright: ignore [reportMissingModuleSource]
|
||||||
|
|
||||||
from . import global_config
|
from .config import Config, get_global_config
|
||||||
from .config import Config
|
|
||||||
from .dcs import get_dcs as _get_dcs, AbstractDCS, Cluster, Member
|
from .dcs import get_dcs as _get_dcs, AbstractDCS, Cluster, Member
|
||||||
from .exceptions import PatroniException
|
from .exceptions import PatroniException
|
||||||
from .postgresql.misc import postgres_version_to_int
|
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 .utils import cluster_as_json, patch_config, polling_loop
|
||||||
from .request import PatroniRequest
|
from .request import PatroniRequest
|
||||||
from .version import __version__
|
from .version import __version__
|
||||||
@@ -261,23 +255,15 @@ def load_config(path: str, dcs_url: Optional[str]) -> Dict[str, Any]:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _get_configuration() -> Dict[str, Any]:
|
|
||||||
"""Get configuration object.
|
|
||||||
|
|
||||||
:returns: configuration object from the current context.
|
|
||||||
"""
|
|
||||||
return click.get_current_context().obj['__config']
|
|
||||||
|
|
||||||
|
|
||||||
option_format = click.option('--format', '-f', 'fmt', help='Output format', default='pretty',
|
option_format = click.option('--format', '-f', 'fmt', help='Output format', default='pretty',
|
||||||
type=click.Choice(['pretty', 'tsv', 'json', 'yaml', 'yml']))
|
type=click.Choice(['pretty', 'tsv', 'json', 'yaml', 'yml']))
|
||||||
option_watchrefresh = click.option('-w', '--watch', type=float, help='Auto update the screen every X seconds')
|
option_watchrefresh = click.option('-w', '--watch', type=float, help='Auto update the screen every X seconds')
|
||||||
option_watch = click.option('-W', is_flag=True, help='Auto update the screen every 2 seconds')
|
option_watch = click.option('-W', is_flag=True, help='Auto update the screen every 2 seconds')
|
||||||
option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point')
|
option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point')
|
||||||
arg_cluster_name = click.argument('cluster_name', required=False,
|
arg_cluster_name = click.argument('cluster_name', required=False,
|
||||||
default=lambda: _get_configuration().get('scope'))
|
default=lambda: click.get_current_context().obj.get('scope'))
|
||||||
option_default_citus_group = click.option('--group', required=False, type=int, help='Citus group',
|
option_default_citus_group = click.option('--group', required=False, type=int, help='Citus group',
|
||||||
default=lambda: _get_configuration().get('citus', {}).get('group'))
|
default=lambda: click.get_current_context().obj.get('citus', {}).get('group'))
|
||||||
option_citus_group = click.option('--group', required=False, type=int, help='Citus group')
|
option_citus_group = click.option('--group', required=False, type=int, help='Citus group')
|
||||||
role_choice = click.Choice(['leader', 'primary', 'standby-leader', 'replica', 'standby', 'any', 'master'])
|
role_choice = click.Choice(['leader', 'primary', 'standby-leader', 'replica', 'standby', 'any', 'master'])
|
||||||
|
|
||||||
@@ -315,23 +301,15 @@ def ctl(ctx: click.Context, config_file: str, dcs_url: Optional[str], insecure:
|
|||||||
level = os.environ.get(name, level)
|
level = os.environ.get(name, level)
|
||||||
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=level)
|
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=level)
|
||||||
logging.captureWarnings(True) # Capture eventual SSL warning
|
logging.captureWarnings(True) # Capture eventual SSL warning
|
||||||
config = load_config(config_file, dcs_url)
|
ctx.obj = load_config(config_file, dcs_url)
|
||||||
# backward compatibility for configuration file where ctl section is not defined
|
# backward compatibility for configuration file where ctl section is not defined
|
||||||
config.setdefault('ctl', {})['insecure'] = config.get('ctl', {}).get('insecure') or insecure
|
ctx.obj.setdefault('ctl', {})['insecure'] = ctx.obj.get('ctl', {}).get('insecure') or insecure
|
||||||
ctx.obj = {'__config': config, '__mpp': get_mpp(config)}
|
|
||||||
|
|
||||||
|
|
||||||
def is_citus_cluster() -> bool:
|
def get_dcs(config: Dict[str, Any], scope: str, group: Optional[int]) -> AbstractDCS:
|
||||||
"""Check if we are working with Citus cluster.
|
|
||||||
|
|
||||||
:returns: ``True`` if configuration has ``citus`` section, otherwise ``False``.
|
|
||||||
"""
|
|
||||||
return click.get_current_context().obj['__mpp'].is_enabled()
|
|
||||||
|
|
||||||
|
|
||||||
def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
|
|
||||||
"""Get the DCS object.
|
"""Get the DCS object.
|
||||||
|
|
||||||
|
:param config: Patroni configuration.
|
||||||
:param scope: cluster name.
|
:param scope: cluster name.
|
||||||
:param group: if *group* is defined, use it to select which alternative Citus group this DCS refers to. If *group*
|
:param group: if *group* is defined, use it to select which alternative Citus group this DCS refers to. If *group*
|
||||||
is ``None`` and a Citus configuration exists, assume this is the coordinator. Coordinator has the group ``0``.
|
is ``None`` and a Citus configuration exists, assume this is the coordinator. Coordinator has the group ``0``.
|
||||||
@@ -342,16 +320,14 @@ def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
|
|||||||
:raises:
|
:raises:
|
||||||
:class:`PatroniCtlException`: if not suitable DCS configuration could be found.
|
:class:`PatroniCtlException`: if not suitable DCS configuration could be found.
|
||||||
"""
|
"""
|
||||||
config = _get_configuration()
|
|
||||||
config.update({'scope': scope, 'patronictl': True})
|
config.update({'scope': scope, 'patronictl': True})
|
||||||
if group is not None:
|
if group is not None:
|
||||||
config['citus'] = {'group': group, 'database': 'postgres'}
|
config['citus'] = {'group': group}
|
||||||
config.setdefault('name', scope)
|
config.setdefault('name', scope)
|
||||||
try:
|
try:
|
||||||
dcs = _get_dcs(config)
|
dcs = _get_dcs(config)
|
||||||
if is_citus_cluster() and group is None:
|
if config.get('citus') and group is None:
|
||||||
dcs.is_mpp_coordinator = lambda: True
|
dcs.is_citus_coordinator = lambda: True
|
||||||
click.get_current_context().obj['__mpp'] = dcs.mpp
|
|
||||||
return dcs
|
return dcs
|
||||||
except PatroniException as e:
|
except PatroniException as e:
|
||||||
raise PatroniCtlException(str(e))
|
raise PatroniCtlException(str(e))
|
||||||
@@ -371,7 +347,7 @@ def request_patroni(member: Member, method: str = 'GET',
|
|||||||
ctx = click.get_current_context() # the current click context
|
ctx = click.get_current_context() # the current click context
|
||||||
request_executor = ctx.obj.get('__request_patroni')
|
request_executor = ctx.obj.get('__request_patroni')
|
||||||
if not request_executor:
|
if not request_executor:
|
||||||
request_executor = ctx.obj['__request_patroni'] = PatroniRequest(_get_configuration())
|
request_executor = ctx.obj['__request_patroni'] = PatroniRequest(ctx.obj)
|
||||||
return request_executor(member, method, endpoint, data)
|
return request_executor(member, method, endpoint, data)
|
||||||
|
|
||||||
|
|
||||||
@@ -438,9 +414,9 @@ def print_output(columns: Optional[List[str]], rows: List[List[Any]], alignment:
|
|||||||
|
|
||||||
|
|
||||||
def watching(w: bool, watch: Optional[int], max_count: Optional[int] = None, clear: bool = True) -> Iterator[int]:
|
def watching(w: bool, watch: Optional[int], max_count: Optional[int] = None, clear: bool = True) -> Iterator[int]:
|
||||||
"""Yield a value every ``watch`` seconds.
|
"""Yield a value every ``x`` seconds.
|
||||||
|
|
||||||
Used to run a command with a watch-based approach.
|
Used to run a command with a watch-based aproach.
|
||||||
|
|
||||||
:param w: if ``True`` and *watch* is ``None``, then *watch* assumes the value ``2``.
|
:param w: if ``True`` and *watch* is ``None``, then *watch* assumes the value ``2``.
|
||||||
:param watch: amount of seconds to wait before yielding another value.
|
:param watch: amount of seconds to wait before yielding another value.
|
||||||
@@ -476,9 +452,11 @@ def watching(w: bool, watch: Optional[int], max_count: Optional[int] = None, cle
|
|||||||
yield 0
|
yield 0
|
||||||
|
|
||||||
|
|
||||||
def get_all_members(cluster: Cluster, group: Optional[int], role: str = 'leader') -> Iterator[Member]:
|
def get_all_members(obj: Dict[str, Any], cluster: Cluster,
|
||||||
|
group: Optional[int], role: str = 'leader') -> Iterator[Member]:
|
||||||
"""Get all cluster members that have the given *role*.
|
"""Get all cluster members that have the given *role*.
|
||||||
|
|
||||||
|
:param obj: the Patroni configuration.
|
||||||
:param cluster: the Patroni cluster.
|
:param cluster: the Patroni cluster.
|
||||||
:param group: filter which Citus group we should get members from. If ``None`` get from all groups.
|
:param group: filter which Citus group we should get members from. If ``None`` get from all groups.
|
||||||
:param role: role to filter members. Can be one among:
|
:param role: role to filter members. Can be one among:
|
||||||
@@ -492,7 +470,7 @@ def get_all_members(cluster: Cluster, group: Optional[int], role: str = 'leader'
|
|||||||
:yields: members that have the given *role*.
|
:yields: members that have the given *role*.
|
||||||
"""
|
"""
|
||||||
clusters = {0: cluster}
|
clusters = {0: cluster}
|
||||||
if is_citus_cluster() and group is None:
|
if obj.get('citus') and group is None:
|
||||||
clusters.update(cluster.workers)
|
clusters.update(cluster.workers)
|
||||||
if role in ('leader', 'master', 'primary', 'standby-leader'):
|
if role in ('leader', 'master', 'primary', 'standby-leader'):
|
||||||
# In the DCS the members' role can be one among: ``primary``, ``master``, ``replica`` or ``standby_leader``.
|
# In the DCS the members' role can be one among: ``primary``, ``master``, ``replica`` or ``standby_leader``.
|
||||||
@@ -514,10 +492,11 @@ def get_all_members(cluster: Cluster, group: Optional[int], role: str = 'leader'
|
|||||||
yield m
|
yield m
|
||||||
|
|
||||||
|
|
||||||
def get_any_member(cluster: Cluster, group: Optional[int],
|
def get_any_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int],
|
||||||
role: Optional[str] = None, member: Optional[str] = None) -> Optional[Member]:
|
role: Optional[str] = None, member: Optional[str] = None) -> Optional[Member]:
|
||||||
"""Get the first found cluster member that has the given *role*.
|
"""Get the first found cluster member that has the given *role*.
|
||||||
|
|
||||||
|
:param obj: the Patroni configuration.
|
||||||
:param cluster: the Patroni cluster.
|
:param cluster: the Patroni cluster.
|
||||||
:param group: filter which Citus group we should get members from. If ``None`` get from all groups.
|
:param group: filter which Citus group we should get members from. If ``None`` get from all groups.
|
||||||
:param role: role to filter members. See :func:`get_all_members` for available options.
|
:param role: role to filter members. See :func:`get_all_members` for available options.
|
||||||
@@ -535,7 +514,7 @@ def get_any_member(cluster: Cluster, group: Optional[int],
|
|||||||
elif role is None:
|
elif role is None:
|
||||||
role = 'leader'
|
role = 'leader'
|
||||||
|
|
||||||
for m in get_all_members(cluster, group, role):
|
for m in get_all_members(obj, cluster, group, role):
|
||||||
if member is None or m.name == member:
|
if member is None or m.name == member:
|
||||||
return m
|
return m
|
||||||
|
|
||||||
@@ -556,7 +535,7 @@ def get_all_members_leader_first(cluster: Cluster) -> Iterator[Member]:
|
|||||||
yield member
|
yield member
|
||||||
|
|
||||||
|
|
||||||
def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[str, Any],
|
def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], connect_parameters: Dict[str, Any],
|
||||||
role: Optional[str] = None, member_name: Optional[str] = None) -> Union['cursor', 'Cursor[Any]', None]:
|
role: Optional[str] = None, member_name: Optional[str] = None) -> Union['cursor', 'Cursor[Any]', None]:
|
||||||
"""Get a cursor object to execute queries against a member that has the given *role* or *member_name*.
|
"""Get a cursor object to execute queries against a member that has the given *role* or *member_name*.
|
||||||
|
|
||||||
@@ -565,6 +544,7 @@ def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[
|
|||||||
* ``fallback_application_name``: as ``Patroni ctl``;
|
* ``fallback_application_name``: as ``Patroni ctl``;
|
||||||
* ``connect_timeout``: as ``5``.
|
* ``connect_timeout``: as ``5``.
|
||||||
|
|
||||||
|
:param obj: the Patroni configuration.
|
||||||
:param cluster: the Patroni cluster.
|
:param cluster: the Patroni cluster.
|
||||||
:param group: filter which Citus group we should get members to create a cursor against. If ``None`` consider
|
:param group: filter which Citus group we should get members to create a cursor against. If ``None`` consider
|
||||||
members from all groups.
|
members from all groups.
|
||||||
@@ -579,7 +559,7 @@ def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[
|
|||||||
* A :class:`psycopg2.extensions.cursor` if using :mod:`psycopg2`;
|
* A :class:`psycopg2.extensions.cursor` if using :mod:`psycopg2`;
|
||||||
* ``None`` if not able to get a cursor that attendees *role* and *member_name*.
|
* ``None`` if not able to get a cursor that attendees *role* and *member_name*.
|
||||||
"""
|
"""
|
||||||
member = get_any_member(cluster, group, role=role, member=member_name)
|
member = get_any_member(obj, cluster, group, role=role, member=member_name)
|
||||||
if member is None:
|
if member is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -614,7 +594,7 @@ def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_members(cluster: Cluster, cluster_name: str, member_names: List[str], role: str,
|
def get_members(obj: Dict[str, Any], cluster: Cluster, cluster_name: str, member_names: List[str], role: str,
|
||||||
force: bool, action: str, ask_confirmation: bool = True, group: Optional[int] = None) -> List[Member]:
|
force: bool, action: str, ask_confirmation: bool = True, group: Optional[int] = None) -> List[Member]:
|
||||||
"""Get the list of members based on the given filters.
|
"""Get the list of members based on the given filters.
|
||||||
|
|
||||||
@@ -638,6 +618,7 @@ def get_members(cluster: Cluster, cluster_name: str, member_names: List[str], ro
|
|||||||
``ask_confirmation=False``, and later call :func:`confirm_members_action` manually in the caller method. That
|
``ask_confirmation=False``, and later call :func:`confirm_members_action` manually in the caller method. That
|
||||||
way the workflow won't look broken to the user that is interacting with ``patronictl``.
|
way the workflow won't look broken to the user that is interacting with ``patronictl``.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster: Patroni cluster.
|
:param cluster: Patroni cluster.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param member_names: used to filter which members should take the *action* based on their names. Each item is the
|
:param member_names: used to filter which members should take the *action* based on their names. Each item is the
|
||||||
@@ -666,13 +647,13 @@ def get_members(cluster: Cluster, cluster_name: str, member_names: List[str], ro
|
|||||||
* Cluster does not have members that match the given *member_names*; or
|
* Cluster does not have members that match the given *member_names*; or
|
||||||
* No member with given *role* is found among the specified *member_names*.
|
* No member with given *role* is found among the specified *member_names*.
|
||||||
"""
|
"""
|
||||||
members = list(get_all_members(cluster, group, role))
|
members = list(get_all_members(obj, cluster, group, role))
|
||||||
|
|
||||||
candidates = {m.name for m in members}
|
candidates = {m.name for m in members}
|
||||||
if not force or role:
|
if not force or role:
|
||||||
if not member_names and not candidates:
|
if not member_names and not candidates:
|
||||||
raise PatroniCtlException('{0} cluster doesn\'t have any members'.format(cluster_name))
|
raise PatroniCtlException('{0} cluster doesn\'t have any members'.format(cluster_name))
|
||||||
output_members(cluster, cluster_name, group=group)
|
output_members(obj, cluster, cluster_name, group=group)
|
||||||
|
|
||||||
if member_names:
|
if member_names:
|
||||||
member_names = list(set(member_names) & candidates)
|
member_names = list(set(member_names) & candidates)
|
||||||
@@ -732,7 +713,9 @@ def confirm_members_action(members: List[Member], force: bool, action: str,
|
|||||||
@click.option('--member', '-m', help='Generate a dsn for this member', type=str)
|
@click.option('--member', '-m', help='Generate a dsn for this member', type=str)
|
||||||
@arg_cluster_name
|
@arg_cluster_name
|
||||||
@option_citus_group
|
@option_citus_group
|
||||||
def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Optional[str]) -> None:
|
@click.pass_obj
|
||||||
|
def dsn(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
|
||||||
|
role: Optional[str], member: Optional[str]) -> None:
|
||||||
"""Process ``dsn`` command of ``patronictl`` utility.
|
"""Process ``dsn`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Get DSN to connect to *member*.
|
Get DSN to connect to *member*.
|
||||||
@@ -740,6 +723,7 @@ def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Op
|
|||||||
.. note::
|
.. note::
|
||||||
If no *role* nor *member* is given assume *role* as ``leader``.
|
If no *role* nor *member* is given assume *role* as ``leader``.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should get members to get DSN from. Refer to the module note for more
|
:param group: filter which Citus group we should get members to get DSN from. Refer to the module note for more
|
||||||
details.
|
details.
|
||||||
@@ -752,8 +736,8 @@ def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Op
|
|||||||
* both *role* and *member* are provided; or
|
* both *role* and *member* are provided; or
|
||||||
* No member matches requested *member* or *role*.
|
* No member matches requested *member* or *role*.
|
||||||
"""
|
"""
|
||||||
cluster = get_dcs(cluster_name, group).get_cluster()
|
cluster = get_dcs(obj, cluster_name, group).get_cluster()
|
||||||
m = get_any_member(cluster, group, role=role, member=member)
|
m = get_any_member(obj, cluster, group, role=role, member=member)
|
||||||
if m is None:
|
if m is None:
|
||||||
raise PatroniCtlException('Can not find a suitable member')
|
raise PatroniCtlException('Can not find a suitable member')
|
||||||
|
|
||||||
@@ -775,7 +759,9 @@ def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Op
|
|||||||
@click.option('--delimiter', help='The column delimiter', default='\t')
|
@click.option('--delimiter', help='The column delimiter', default='\t')
|
||||||
@click.option('--command', '-c', help='The SQL commands to execute')
|
@click.option('--command', '-c', help='The SQL commands to execute')
|
||||||
@click.option('-d', '--dbname', help='database name to connect to', type=str)
|
@click.option('-d', '--dbname', help='database name to connect to', type=str)
|
||||||
|
@click.pass_obj
|
||||||
def query(
|
def query(
|
||||||
|
obj: Dict[str, Any],
|
||||||
cluster_name: str,
|
cluster_name: str,
|
||||||
group: Optional[int],
|
group: Optional[int],
|
||||||
role: Optional[str],
|
role: Optional[str],
|
||||||
@@ -794,6 +780,7 @@ def query(
|
|||||||
|
|
||||||
Perform a Postgres query in a Patroni node.
|
Perform a Postgres query in a Patroni node.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should get members from to perform the query. Refer to the module note for
|
:param group: filter which Citus group we should get members from to perform the query. Refer to the module note for
|
||||||
more details.
|
more details.
|
||||||
@@ -833,22 +820,24 @@ def query(
|
|||||||
if dbname:
|
if dbname:
|
||||||
connect_parameters['dbname'] = dbname
|
connect_parameters['dbname'] = dbname
|
||||||
|
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(obj, cluster_name, group)
|
||||||
|
|
||||||
cluster = cursor = None
|
cluster = cursor = None
|
||||||
for _ in watching(w, watch, clear=False):
|
for _ in watching(w, watch, clear=False):
|
||||||
if cluster is None:
|
if cluster is None:
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
|
# cursor = get_cursor(obj, cluster, group, connect_parameters, role=role, member=member)
|
||||||
|
|
||||||
output, header = query_member(cluster, group, cursor, member, role, sql, connect_parameters)
|
output, header = query_member(obj, cluster, group, cursor, member, role, sql, connect_parameters)
|
||||||
print_output(header, output, fmt=fmt, delimiter=delimiter)
|
print_output(header, output, fmt=fmt, delimiter=delimiter)
|
||||||
|
|
||||||
|
|
||||||
def query_member(cluster: Cluster, group: Optional[int], cursor: Union['cursor', 'Cursor[Any]', None],
|
def query_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int],
|
||||||
member: Optional[str], role: Optional[str], command: str,
|
cursor: Union['cursor', 'Cursor[Any]', None], member: Optional[str], role: Optional[str],
|
||||||
connect_parameters: Dict[str, Any]) -> Tuple[List[List[Any]], Optional[List[Any]]]:
|
command: str, connect_parameters: Dict[str, Any]) -> Tuple[List[List[Any]], Optional[List[Any]]]:
|
||||||
"""Execute SQL *command* against a member.
|
"""Execute SQL *command* against a member.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster: the Patroni cluster.
|
:param cluster: the Patroni cluster.
|
||||||
:param group: filter which Citus group we should get members from to perform the query. Refer to the module note for
|
:param group: filter which Citus group we should get members from to perform the query. Refer to the module note for
|
||||||
more details.
|
more details.
|
||||||
@@ -877,7 +866,7 @@ def query_member(cluster: Cluster, group: Optional[int], cursor: Union['cursor',
|
|||||||
from . import psycopg
|
from . import psycopg
|
||||||
try:
|
try:
|
||||||
if cursor is None:
|
if cursor is None:
|
||||||
cursor = get_cursor(cluster, group, connect_parameters, role=role, member_name=member)
|
cursor = get_cursor(obj, cluster, group, connect_parameters, role=role, member_name=member)
|
||||||
|
|
||||||
if cursor is None:
|
if cursor is None:
|
||||||
if member is not None:
|
if member is not None:
|
||||||
@@ -904,11 +893,13 @@ def query_member(cluster: Cluster, group: Optional[int], cursor: Union['cursor',
|
|||||||
@click.argument('cluster_name')
|
@click.argument('cluster_name')
|
||||||
@option_citus_group
|
@option_citus_group
|
||||||
@option_format
|
@option_format
|
||||||
def remove(cluster_name: str, group: Optional[int], fmt: str) -> None:
|
@click.pass_obj
|
||||||
|
def remove(obj: Dict[str, Any], cluster_name: str, group: Optional[int], fmt: str) -> None:
|
||||||
"""Process ``remove`` command of ``patronictl`` utility.
|
"""Process ``remove`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Remove cluster *cluster_name* from the DCS.
|
Remove cluster *cluster_name* from the DCS.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the cluster which information will be wiped out of the DCS.
|
:param cluster_name: name of the cluster which information will be wiped out of the DCS.
|
||||||
:param group: which Citus group should have its information wiped out of the DCS. Refer to the module note for more
|
:param group: which Citus group should have its information wiped out of the DCS. Refer to the module note for more
|
||||||
details.
|
details.
|
||||||
@@ -922,12 +913,12 @@ def remove(cluster_name: str, group: Optional[int], fmt: str) -> None:
|
|||||||
* use did not type the correct leader name when requesting removal of a healthy cluster.
|
* use did not type the correct leader name when requesting removal of a healthy cluster.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(obj, cluster_name, group)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
if is_citus_cluster() and group is None:
|
if obj.get('citus') and group is None:
|
||||||
raise PatroniCtlException('For Citus clusters the --group must me specified')
|
raise PatroniCtlException('For Citus clusters the --group must me specified')
|
||||||
output_members(cluster, cluster_name, fmt=fmt)
|
output_members(obj, cluster, cluster_name, fmt=fmt)
|
||||||
|
|
||||||
confirm = click.prompt('Please confirm the cluster name to remove', type=str)
|
confirm = click.prompt('Please confirm the cluster name to remove', type=str)
|
||||||
if confirm != cluster_name:
|
if confirm != cluster_name:
|
||||||
@@ -1012,28 +1003,31 @@ def parse_scheduled(scheduled: Optional[str]) -> Optional[datetime.datetime]:
|
|||||||
@option_citus_group
|
@option_citus_group
|
||||||
@click.option('--role', '-r', help='Reload only members with this role', type=role_choice, default='any')
|
@click.option('--role', '-r', help='Reload only members with this role', type=role_choice, default='any')
|
||||||
@option_force
|
@option_force
|
||||||
def reload(cluster_name: str, member_names: List[str], group: Optional[int], force: bool, role: str) -> None:
|
@click.pass_obj
|
||||||
|
def reload(obj: Dict[str, Any], cluster_name: str, member_names: List[str],
|
||||||
|
group: Optional[int], force: bool, role: str) -> None:
|
||||||
"""Process ``reload`` command of ``patronictl`` utility.
|
"""Process ``reload`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Reload configuration of cluster members based on given filters.
|
Reload configuration of cluster members based on given filters.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param member_names: name of the members which configuration should be reloaded.
|
:param member_names: name of the members which configuration should be reloaded.
|
||||||
:param group: filter which Citus group we should reload members. Refer to the module note for more details.
|
:param group: filter which Citus group we should reload members. Refer to the module note for more details.
|
||||||
:param force: perform the reload without asking for confirmations.
|
:param force: perform the reload without asking for confirmations.
|
||||||
:param role: role to filter members. See :func:`get_all_members` for available options.
|
:param role: role to filter members. See :func:`get_all_members` for available options.
|
||||||
"""
|
"""
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(obj, cluster_name, group)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
members = get_members(cluster, cluster_name, member_names, role, force, 'reload', group=group)
|
members = get_members(obj, cluster, cluster_name, member_names, role, force, 'reload', group=group)
|
||||||
|
|
||||||
for member in members:
|
for member in members:
|
||||||
r = request_patroni(member, 'post', 'reload')
|
r = request_patroni(member, 'post', 'reload')
|
||||||
if r.status == 200:
|
if r.status == 200:
|
||||||
click.echo('No changes to apply on member {0}'.format(member.name))
|
click.echo('No changes to apply on member {0}'.format(member.name))
|
||||||
elif r.status == 202:
|
elif r.status == 202:
|
||||||
config = global_config.from_cluster(cluster)
|
config = get_global_config(cluster)
|
||||||
click.echo('Reload request received for member {0} and will be processed within {1} seconds'.format(
|
click.echo('Reload request received for member {0} and will be processed within {1} seconds'.format(
|
||||||
member.name, config.get('loop_wait') or dcs.loop_wait)
|
member.name, config.get('loop_wait') or dcs.loop_wait)
|
||||||
)
|
)
|
||||||
@@ -1056,13 +1050,15 @@ def reload(cluster_name: str, member_names: List[str], group: Optional[int], for
|
|||||||
@click.option('--pending', help='Restart if pending', is_flag=True)
|
@click.option('--pending', help='Restart if pending', is_flag=True)
|
||||||
@click.option('--timeout', help='Return error and fail over if necessary when restarting takes longer than this.')
|
@click.option('--timeout', help='Return error and fail over if necessary when restarting takes longer than this.')
|
||||||
@option_force
|
@option_force
|
||||||
def restart(cluster_name: str, group: Optional[int], member_names: List[str],
|
@click.pass_obj
|
||||||
|
def restart(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member_names: List[str],
|
||||||
force: bool, role: str, p_any: bool, scheduled: Optional[str], version: Optional[str],
|
force: bool, role: str, p_any: bool, scheduled: Optional[str], version: Optional[str],
|
||||||
pending: bool, timeout: Optional[str]) -> None:
|
pending: bool, timeout: Optional[str]) -> None:
|
||||||
"""Process ``restart`` command of ``patronictl`` utility.
|
"""Process ``restart`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Restart Postgres on cluster members based on given filters.
|
Restart Postgres on cluster members based on given filters.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should restart members. Refer to the module note for more details.
|
:param group: filter which Citus group we should restart members. Refer to the module note for more details.
|
||||||
:param member_names: name of the members that should be restarted.
|
:param member_names: name of the members that should be restarted.
|
||||||
@@ -1080,9 +1076,9 @@ def restart(cluster_name: str, group: Optional[int], member_names: List[str],
|
|||||||
* *version* could not be parsed; or
|
* *version* could not be parsed; or
|
||||||
* a restart is attempted against a cluster that is in maintenance mode.
|
* a restart is attempted against a cluster that is in maintenance mode.
|
||||||
"""
|
"""
|
||||||
cluster = get_dcs(cluster_name, group).get_cluster()
|
cluster = get_dcs(obj, cluster_name, group).get_cluster()
|
||||||
|
|
||||||
members = get_members(cluster, cluster_name, member_names, role, force, 'restart', False, group=group)
|
members = get_members(obj, cluster, cluster_name, member_names, role, force, 'restart', False, group=group)
|
||||||
if scheduled is None and not force:
|
if scheduled is None and not force:
|
||||||
next_hour = (datetime.datetime.now() + datetime.timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M')
|
next_hour = (datetime.datetime.now() + datetime.timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M')
|
||||||
scheduled = click.prompt('When should the restart take place (e.g. ' + next_hour + ') ',
|
scheduled = click.prompt('When should the restart take place (e.g. ' + next_hour + ') ',
|
||||||
@@ -1112,7 +1108,7 @@ def restart(cluster_name: str, group: Optional[int], member_names: List[str],
|
|||||||
content['postgres_version'] = version
|
content['postgres_version'] = version
|
||||||
|
|
||||||
if scheduled_at:
|
if scheduled_at:
|
||||||
if global_config.from_cluster(cluster).is_paused:
|
if get_global_config(cluster).is_paused:
|
||||||
raise PatroniCtlException("Can't schedule restart in the paused state")
|
raise PatroniCtlException("Can't schedule restart in the paused state")
|
||||||
content['schedule'] = scheduled_at.isoformat()
|
content['schedule'] = scheduled_at.isoformat()
|
||||||
|
|
||||||
@@ -1144,7 +1140,9 @@ def restart(cluster_name: str, group: Optional[int], member_names: List[str],
|
|||||||
@click.argument('member_names', nargs=-1)
|
@click.argument('member_names', nargs=-1)
|
||||||
@option_force
|
@option_force
|
||||||
@click.option('--wait', help='Wait until reinitialization completes', is_flag=True)
|
@click.option('--wait', help='Wait until reinitialization completes', is_flag=True)
|
||||||
def reinit(cluster_name: str, group: Optional[int], member_names: List[str], force: bool, wait: bool) -> None:
|
@click.pass_obj
|
||||||
|
def reinit(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
|
||||||
|
member_names: List[str], force: bool, wait: bool) -> None:
|
||||||
"""Process ``reinit`` command of ``patronictl`` utility.
|
"""Process ``reinit`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Reinitialize cluster members based on given filters.
|
Reinitialize cluster members based on given filters.
|
||||||
@@ -1152,14 +1150,15 @@ def reinit(cluster_name: str, group: Optional[int], member_names: List[str], for
|
|||||||
.. note::
|
.. note::
|
||||||
Only reinitialize replica members, not a leader.
|
Only reinitialize replica members, not a leader.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should reinit members. Refer to the module note for more details.
|
:param group: filter which Citus group we should reinit members. Refer to the module note for more details.
|
||||||
:param member_names: name of the members that should be reinitialized.
|
:param member_names: name of the members that should be reinitialized.
|
||||||
:param force: perform the restart without asking for confirmations.
|
:param force: perform the restart without asking for confirmations.
|
||||||
:param wait: wait for the operation to complete.
|
:param wait: wait for the operation to complete.
|
||||||
"""
|
"""
|
||||||
cluster = get_dcs(cluster_name, group).get_cluster()
|
cluster = get_dcs(obj, cluster_name, group).get_cluster()
|
||||||
members = get_members(cluster, cluster_name, member_names, 'replica', force, 'reinitialize', group=group)
|
members = get_members(obj, cluster, cluster_name, member_names, 'replica', force, 'reinitialize', group=group)
|
||||||
|
|
||||||
wait_on_members: List[Member] = []
|
wait_on_members: List[Member] = []
|
||||||
for member in members:
|
for member in members:
|
||||||
@@ -1190,8 +1189,8 @@ def reinit(cluster_name: str, group: Optional[int], member_names: List[str], for
|
|||||||
wait_on_members.remove(member)
|
wait_on_members.remove(member)
|
||||||
|
|
||||||
|
|
||||||
def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[int],
|
def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: str,
|
||||||
switchover_leader: Optional[str], candidate: Optional[str],
|
group: Optional[int], switchover_leader: Optional[str], candidate: Optional[str],
|
||||||
force: bool, scheduled: Optional[str] = None) -> None:
|
force: bool, scheduled: Optional[str] = None) -> None:
|
||||||
"""Perform a failover or a switchover operation in the cluster.
|
"""Perform a failover or a switchover operation in the cluster.
|
||||||
|
|
||||||
@@ -1201,6 +1200,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
|||||||
.. note::
|
.. note::
|
||||||
If not able to perform the operation through the REST API, write directly to the DCS as a fall back.
|
If not able to perform the operation through the REST API, write directly to the DCS as a fall back.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param action: action to be taken -- ``failover`` or ``switchover``.
|
:param action: action to be taken -- ``failover`` or ``switchover``.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
: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
|
:param group: filter Citus group within we should perform a failover or switchover. If ``None``, user will be
|
||||||
@@ -1223,20 +1223,20 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
|||||||
* trying to schedule a switchover in a cluster that is in maintenance mode; or
|
* trying to schedule a switchover in a cluster that is in maintenance mode; or
|
||||||
* user aborts the operation.
|
* user aborts the operation.
|
||||||
"""
|
"""
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(obj, cluster_name, group)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
click.echo('Current cluster topology')
|
click.echo('Current cluster topology')
|
||||||
output_members(cluster, cluster_name, group=group)
|
output_members(obj, cluster, cluster_name, group=group)
|
||||||
|
|
||||||
if is_citus_cluster() and group is None:
|
if obj.get('citus') and group is None:
|
||||||
if force:
|
if force:
|
||||||
raise PatroniCtlException('For Citus clusters the --group must me specified')
|
raise PatroniCtlException('For Citus clusters the --group must me specified')
|
||||||
else:
|
else:
|
||||||
group = click.prompt('Citus group', type=int)
|
group = click.prompt('Citus group', type=int)
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(obj, cluster_name, group)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
config = global_config.from_cluster(cluster)
|
global_config = get_global_config(cluster)
|
||||||
|
|
||||||
cluster_leader = cluster.leader and cluster.leader.name
|
cluster_leader = cluster.leader and cluster.leader.name
|
||||||
# leader has to be be defined for switchover only
|
# leader has to be be defined for switchover only
|
||||||
@@ -1248,7 +1248,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
|||||||
if force:
|
if force:
|
||||||
switchover_leader = cluster_leader
|
switchover_leader = cluster_leader
|
||||||
else:
|
else:
|
||||||
prompt = 'Standby Leader' if config.is_standby_cluster else 'Primary'
|
prompt = 'Standby Leader' if global_config.is_standby_cluster else 'Primary'
|
||||||
switchover_leader = click.prompt(prompt, type=str, default=cluster_leader)
|
switchover_leader = click.prompt(prompt, type=str, default=cluster_leader)
|
||||||
|
|
||||||
if cluster_leader != switchover_leader:
|
if cluster_leader != switchover_leader:
|
||||||
@@ -1277,7 +1277,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
|||||||
|
|
||||||
if all((not force,
|
if all((not force,
|
||||||
action == 'failover',
|
action == 'failover',
|
||||||
config.is_synchronous_mode,
|
global_config.is_synchronous_mode,
|
||||||
not cluster.sync.is_empty,
|
not cluster.sync.is_empty,
|
||||||
not cluster.sync.matches(candidate, True))):
|
not cluster.sync.matches(candidate, True))):
|
||||||
if not click.confirm(f'Are you sure you want to failover to the asynchronous node {candidate}?'):
|
if not click.confirm(f'Are you sure you want to failover to the asynchronous node {candidate}?'):
|
||||||
@@ -1294,7 +1294,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
|||||||
|
|
||||||
scheduled_at = parse_scheduled(scheduled)
|
scheduled_at = parse_scheduled(scheduled)
|
||||||
if scheduled_at:
|
if scheduled_at:
|
||||||
if config.is_paused:
|
if global_config.is_paused:
|
||||||
raise PatroniCtlException("Can't schedule switchover in the paused state")
|
raise PatroniCtlException("Can't schedule switchover in the paused state")
|
||||||
scheduled_at_str = scheduled_at.isoformat()
|
scheduled_at_str = scheduled_at.isoformat()
|
||||||
|
|
||||||
@@ -1344,7 +1344,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
|||||||
click.echo('{0} Could not {1} using Patroni api, falling back to DCS'.format(timestamp(), action))
|
click.echo('{0} Could not {1} using Patroni api, falling back to DCS'.format(timestamp(), action))
|
||||||
dcs.manual_failover(switchover_leader, candidate, scheduled_at=scheduled_at)
|
dcs.manual_failover(switchover_leader, candidate, scheduled_at=scheduled_at)
|
||||||
|
|
||||||
output_members(cluster, cluster_name, group=group)
|
output_members(obj, cluster, cluster_name, group=group)
|
||||||
|
|
||||||
|
|
||||||
@ctl.command('failover', help='Failover to a replica')
|
@ctl.command('failover', help='Failover to a replica')
|
||||||
@@ -1353,7 +1353,8 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
|
|||||||
@click.option('--leader', '--primary', '--master', 'leader', help='The name of the current leader', default=None)
|
@click.option('--leader', '--primary', '--master', 'leader', help='The name of the current leader', default=None)
|
||||||
@click.option('--candidate', help='The name of the candidate', default=None)
|
@click.option('--candidate', help='The name of the candidate', default=None)
|
||||||
@option_force
|
@option_force
|
||||||
def failover(cluster_name: str, group: Optional[int],
|
@click.pass_obj
|
||||||
|
def failover(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
|
||||||
leader: Optional[str], candidate: Optional[str], force: bool) -> None:
|
leader: Optional[str], candidate: Optional[str], force: bool) -> None:
|
||||||
"""Process ``failover`` command of ``patronictl`` utility.
|
"""Process ``failover`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
@@ -1367,6 +1368,7 @@ def failover(cluster_name: str, group: Optional[int],
|
|||||||
.. seealso::
|
.. seealso::
|
||||||
Refer to :func:`_do_failover_or_switchover` for details.
|
Refer to :func:`_do_failover_or_switchover` for details.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
: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
|
: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 by
|
prompted for filling it -- unless *force* is ``True``, in which case an exception is raised by
|
||||||
@@ -1381,7 +1383,7 @@ def failover(cluster_name: str, group: Optional[int],
|
|||||||
click.echo(click.style(
|
click.echo(click.style(
|
||||||
'Supplying a leader name using this command is deprecated and will be removed in a future version of'
|
'Supplying a leader name using this command is deprecated and will be removed in a future version of'
|
||||||
' Patroni, change your scripts to use `switchover` instead.\nExecuting switchover!', fg='red'))
|
' Patroni, change your scripts to use `switchover` instead.\nExecuting switchover!', fg='red'))
|
||||||
_do_failover_or_switchover(action, cluster_name, group, leader, candidate, force)
|
_do_failover_or_switchover(obj, action, cluster_name, group, leader, candidate, force)
|
||||||
|
|
||||||
|
|
||||||
@ctl.command('switchover', help='Switchover to a replica')
|
@ctl.command('switchover', help='Switchover to a replica')
|
||||||
@@ -1392,8 +1394,9 @@ def failover(cluster_name: str, group: Optional[int],
|
|||||||
@click.option('--scheduled', help='Timestamp of a scheduled switchover in unambiguous format (e.g. ISO 8601)',
|
@click.option('--scheduled', help='Timestamp of a scheduled switchover in unambiguous format (e.g. ISO 8601)',
|
||||||
default=None)
|
default=None)
|
||||||
@option_force
|
@option_force
|
||||||
def switchover(cluster_name: str, group: Optional[int], leader: Optional[str],
|
@click.pass_obj
|
||||||
candidate: Optional[str], force: bool, scheduled: Optional[str]) -> None:
|
def switchover(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
|
||||||
|
leader: Optional[str], candidate: Optional[str], force: bool, scheduled: Optional[str]) -> None:
|
||||||
"""Process ``switchover`` command of ``patronictl`` utility.
|
"""Process ``switchover`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Perform a switchover operation in the cluster.
|
Perform a switchover operation in the cluster.
|
||||||
@@ -1401,6 +1404,7 @@ def switchover(cluster_name: str, group: Optional[int], leader: Optional[str],
|
|||||||
.. seealso::
|
.. seealso::
|
||||||
Refer to :func:`_do_failover_or_switchover` for details.
|
Refer to :func:`_do_failover_or_switchover` for details.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter Citus group within we should perform a switchover. If ``None``, user will be prompted for
|
:param group: filter Citus group within we should perform a switchover. If ``None``, user will be prompted for
|
||||||
filling it -- unless *force* is ``True``, in which case an exception is raised by
|
filling it -- unless *force* is ``True``, in which case an exception is raised by
|
||||||
@@ -1410,7 +1414,7 @@ def switchover(cluster_name: str, group: Optional[int], leader: Optional[str],
|
|||||||
:param force: perform the switchover without asking for confirmations.
|
:param force: perform the switchover without asking for confirmations.
|
||||||
:param scheduled: timestamp when the switchover should be scheduled to occur. If ``now`` perform immediately.
|
:param scheduled: timestamp when the switchover should be scheduled to occur. If ``now`` perform immediately.
|
||||||
"""
|
"""
|
||||||
_do_failover_or_switchover('switchover', cluster_name, group, leader, candidate, force, scheduled)
|
_do_failover_or_switchover(obj, 'switchover', cluster_name, group, leader, candidate, force, scheduled)
|
||||||
|
|
||||||
|
|
||||||
def generate_topology(level: int, member: Dict[str, Any],
|
def generate_topology(level: int, member: Dict[str, Any],
|
||||||
@@ -1512,8 +1516,8 @@ def get_cluster_service_info(cluster: Dict[str, Any]) -> List[str]:
|
|||||||
return service_info
|
return service_info
|
||||||
|
|
||||||
|
|
||||||
def output_members(cluster: Cluster, name: str, extended: bool = False,
|
def output_members(obj: Dict[str, Any], cluster: Cluster, name: str,
|
||||||
fmt: str = 'pretty', group: Optional[int] = None) -> None:
|
extended: bool = False, fmt: str = 'pretty', group: Optional[int] = None) -> None:
|
||||||
"""Print information about the Patroni cluster and its members.
|
"""Print information about the Patroni cluster and its members.
|
||||||
|
|
||||||
Information is printed to console through :func:`print_output`, and contains:
|
Information is printed to console through :func:`print_output`, and contains:
|
||||||
@@ -1538,6 +1542,7 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
|
|||||||
The 3 extended columns are always included if *extended*, even if the member has no value for a given column.
|
The 3 extended columns are always included if *extended*, even if the member has no value for a given column.
|
||||||
If not *extended*, these columns may still be shown if any of the members has any information for them.
|
If not *extended*, these columns may still be shown if any of the members has any information for them.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster: Patroni cluster.
|
:param cluster: Patroni cluster.
|
||||||
:param name: name of the Patroni cluster.
|
:param name: name of the Patroni cluster.
|
||||||
:param extended: if extended information (pending restarts, scheduled restarts, node tags) should be printed, if
|
:param extended: if extended information (pending restarts, scheduled restarts, node tags) should be printed, if
|
||||||
@@ -1555,14 +1560,15 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
|
|||||||
|
|
||||||
clusters = {group or 0: cluster_as_json(cluster)}
|
clusters = {group or 0: cluster_as_json(cluster)}
|
||||||
|
|
||||||
if is_citus_cluster():
|
is_citus_cluster = obj.get('citus')
|
||||||
|
if is_citus_cluster:
|
||||||
columns.insert(1, 'Group')
|
columns.insert(1, 'Group')
|
||||||
if group is None:
|
if group is None:
|
||||||
clusters.update({g: cluster_as_json(c) for g, c in cluster.workers.items()})
|
clusters.update({g: cluster_as_json(c) for g, c in cluster.workers.items()})
|
||||||
|
|
||||||
all_members = [m for c in clusters.values() for m in c['members'] if 'host' in m]
|
all_members = [m for c in clusters.values() for m in c['members'] if 'host' in m]
|
||||||
|
|
||||||
for c in ('Pending restart', 'Pending restart reason', 'Scheduled restart', 'Tags'):
|
for c in ('Pending restart', 'Scheduled restart', 'Tags'):
|
||||||
if extended or any(m.get(c.lower().replace(' ', '_')) for m in all_members):
|
if extended or any(m.get(c.lower().replace(' ', '_')) for m in all_members):
|
||||||
columns.append(c)
|
columns.append(c)
|
||||||
|
|
||||||
@@ -1576,19 +1582,11 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
|
|||||||
logging.debug(member)
|
logging.debug(member)
|
||||||
|
|
||||||
lag = member.get('lag', '')
|
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,
|
member.update(cluster=name, member=member['name'], group=g,
|
||||||
host=member.get('host', ''), tl=member.get('timeline', ''),
|
host=member.get('host', ''), tl=member.get('timeline', ''),
|
||||||
role=member['role'].replace('_', ' ').title(),
|
role=member['role'].replace('_', ' ').title(),
|
||||||
lag_in_mb=round(lag / 1024 / 1024) if isinstance(lag, int) else lag,
|
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'):
|
if append_port and member['host'] and member.get('port'):
|
||||||
member['host'] = ':'.join([member['host'], str(member['port'])])
|
member['host'] = ':'.join([member['host'], str(member['port'])])
|
||||||
@@ -1601,12 +1599,10 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
|
|||||||
|
|
||||||
rows.append([member.get(n.lower().replace(' ', '_'), '') for n in columns])
|
rows.append([member.get(n.lower().replace(' ', '_'), '') for n in columns])
|
||||||
|
|
||||||
if is_citus_cluster():
|
title = 'Citus cluster' if is_citus_cluster else 'Cluster'
|
||||||
title = 'Citus cluster'
|
|
||||||
title_details = '' if group is None else f' (group: {group}, {initialize})'
|
|
||||||
else:
|
|
||||||
title = 'Cluster'
|
|
||||||
title_details = f' ({initialize})'
|
title_details = f' ({initialize})'
|
||||||
|
if is_citus_cluster:
|
||||||
|
title_details = '' if group is None else f' (group: {group}, {initialize})'
|
||||||
|
|
||||||
title = f' {title}: {name}{title_details} '
|
title = f' {title}: {name}{title_details} '
|
||||||
print_output(columns, rows, {'Group': 'r', 'Lag in MB': 'r', 'TL': 'r'}, fmt, title)
|
print_output(columns, rows, {'Group': 'r', 'Lag in MB': 'r', 'TL': 'r'}, fmt, title)
|
||||||
@@ -1617,7 +1613,7 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
|
|||||||
for g, c in sorted(clusters.items()):
|
for g, c in sorted(clusters.items()):
|
||||||
service_info = get_cluster_service_info(c)
|
service_info = get_cluster_service_info(c)
|
||||||
if service_info:
|
if service_info:
|
||||||
if is_citus_cluster() and group is None:
|
if is_citus_cluster and group is None:
|
||||||
click.echo('Citus group: {0}'.format(g))
|
click.echo('Citus group: {0}'.format(g))
|
||||||
click.echo(' ' + '\n '.join(service_info))
|
click.echo(' ' + '\n '.join(service_info))
|
||||||
|
|
||||||
@@ -1630,14 +1626,16 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
|
|||||||
@option_format
|
@option_format
|
||||||
@option_watch
|
@option_watch
|
||||||
@option_watchrefresh
|
@option_watchrefresh
|
||||||
def members(cluster_names: List[str], group: Optional[int], fmt: str,
|
@click.pass_obj
|
||||||
watch: Optional[int], w: bool, extended: bool, ts: bool) -> None:
|
def members(obj: Dict[str, Any], cluster_names: List[str], group: Optional[int],
|
||||||
|
fmt: str, watch: Optional[int], w: bool, extended: bool, ts: bool) -> None:
|
||||||
"""Process ``list`` command of ``patronictl`` utility.
|
"""Process ``list`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Print information about the Patroni cluster through :func:`output_members`.
|
Print information about the Patroni cluster through :func:`output_members`.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_names: name of clusters that should be printed. If ``None`` consider only the cluster present in
|
:param cluster_names: name of clusters that should be printed. If ``None`` consider only the cluster present in
|
||||||
``scope`` key of the configuration.
|
``scope`` key of *obj*.
|
||||||
:param group: filter which Citus group we should get members from. Refer to the module note for more details.
|
:param group: filter which Citus group we should get members from. Refer to the module note for more details.
|
||||||
:param fmt: the output table printing format. See :func:`print_output` for available options.
|
:param fmt: the output table printing format. See :func:`print_output` for available options.
|
||||||
:param watch: if given print output every *watch* seconds.
|
:param watch: if given print output every *watch* seconds.
|
||||||
@@ -1646,10 +1644,9 @@ def members(cluster_names: List[str], group: Optional[int], fmt: str,
|
|||||||
more details.
|
more details.
|
||||||
:param ts: if timestamp should be included in the output.
|
:param ts: if timestamp should be included in the output.
|
||||||
"""
|
"""
|
||||||
config = _get_configuration()
|
|
||||||
if not cluster_names:
|
if not cluster_names:
|
||||||
if 'scope' in config:
|
if 'scope' in obj:
|
||||||
cluster_names = [config['scope']]
|
cluster_names = [obj['scope']]
|
||||||
if not cluster_names:
|
if not cluster_names:
|
||||||
return logging.warning('Listing members: No cluster names were provided')
|
return logging.warning('Listing members: No cluster names were provided')
|
||||||
|
|
||||||
@@ -1658,10 +1655,10 @@ def members(cluster_names: List[str], group: Optional[int], fmt: str,
|
|||||||
click.echo(timestamp(0))
|
click.echo(timestamp(0))
|
||||||
|
|
||||||
for cluster_name in cluster_names:
|
for cluster_name in cluster_names:
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(obj, cluster_name, group)
|
||||||
|
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
output_members(cluster, cluster_name, extended, fmt, group)
|
output_members(obj, cluster, cluster_name, extended, fmt, group)
|
||||||
|
|
||||||
|
|
||||||
@ctl.command('topology', help='Prints ASCII topology for given cluster')
|
@ctl.command('topology', help='Prints ASCII topology for given cluster')
|
||||||
@@ -1703,12 +1700,14 @@ def timestamp(precision: int = 6) -> str:
|
|||||||
@click.argument('target', type=click.Choice(['restart', 'switchover']))
|
@click.argument('target', type=click.Choice(['restart', 'switchover']))
|
||||||
@click.option('--role', '-r', help='Flush only members with this role', type=role_choice, default='any')
|
@click.option('--role', '-r', help='Flush only members with this role', type=role_choice, default='any')
|
||||||
@option_force
|
@option_force
|
||||||
def flush(cluster_name: str, group: Optional[int],
|
@click.pass_obj
|
||||||
|
def flush(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
|
||||||
member_names: List[str], force: bool, role: str, target: str) -> None:
|
member_names: List[str], force: bool, role: str, target: str) -> None:
|
||||||
"""Process ``flush`` command of ``patronictl`` utility.
|
"""Process ``flush`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Discard scheduled restart or switchover events.
|
Discard scheduled restart or switchover events.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should flush an event. Refer to the module note for more details.
|
:param group: filter which Citus group we should flush an event. Refer to the module note for more details.
|
||||||
:param member_names: name of the members which events should be flushed.
|
:param member_names: name of the members which events should be flushed.
|
||||||
@@ -1716,11 +1715,11 @@ def flush(cluster_name: str, group: Optional[int],
|
|||||||
:param role: role to filter members. See :func:`get_all_members` for available options.
|
:param role: role to filter members. See :func:`get_all_members` for available options.
|
||||||
:param target: the event that should be flushed -- ``restart`` or ``switchover``.
|
:param target: the event that should be flushed -- ``restart`` or ``switchover``.
|
||||||
"""
|
"""
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(obj, cluster_name, group)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
if target == 'restart':
|
if target == 'restart':
|
||||||
for member in get_members(cluster, cluster_name, member_names, role, force, 'flush', group=group):
|
for member in get_members(obj, cluster, cluster_name, member_names, role, force, 'flush', group=group):
|
||||||
if member.data.get('scheduled_restart'):
|
if member.data.get('scheduled_restart'):
|
||||||
r = request_patroni(member, 'delete', 'restart')
|
r = request_patroni(member, 'delete', 'restart')
|
||||||
check_response(r, member.name, 'flush scheduled restart')
|
check_response(r, member.name, 'flush scheduled restart')
|
||||||
@@ -1756,7 +1755,7 @@ def wait_until_pause_is_applied(dcs: AbstractDCS, paused: bool, old_cluster: Clu
|
|||||||
:param old_cluster: original cluster information before pause or unpause has been requested. Used to report which
|
:param old_cluster: original cluster information before pause or unpause has been requested. Used to report which
|
||||||
nodes are still pending to have ``pause`` equal *paused* at a given point in time.
|
nodes are still pending to have ``pause`` equal *paused* at a given point in time.
|
||||||
"""
|
"""
|
||||||
config = global_config.from_cluster(old_cluster)
|
config = get_global_config(old_cluster)
|
||||||
|
|
||||||
click.echo("'{0}' request sent, waiting until it is recognized by all nodes".format(paused and 'pause' or 'resume'))
|
click.echo("'{0}' request sent, waiting until it is recognized by all nodes".format(paused and 'pause' or 'resume'))
|
||||||
old = {m.name: m.version for m in old_cluster.members if m.api_url}
|
old = {m.name: m.version for m in old_cluster.members if m.api_url}
|
||||||
@@ -1778,9 +1777,10 @@ def wait_until_pause_is_applied(dcs: AbstractDCS, paused: bool, old_cluster: Clu
|
|||||||
return click.echo('Success: cluster management is {0}'.format(paused and 'paused' or 'resumed'))
|
return click.echo('Success: cluster management is {0}'.format(paused and 'paused' or 'resumed'))
|
||||||
|
|
||||||
|
|
||||||
def toggle_pause(cluster_name: str, group: Optional[int], paused: bool, wait: bool) -> None:
|
def toggle_pause(config: Dict[str, Any], cluster_name: str, group: Optional[int], paused: bool, wait: bool) -> None:
|
||||||
"""Toggle the ``pause`` state in the cluster members.
|
"""Toggle the ``pause`` state in the cluster members.
|
||||||
|
|
||||||
|
:param config: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should toggle the pause state of. Refer to the module note for more
|
:param group: filter which Citus group we should toggle the pause state of. Refer to the module note for more
|
||||||
details.
|
details.
|
||||||
@@ -1792,9 +1792,9 @@ def toggle_pause(cluster_name: str, group: Optional[int], paused: bool, wait: bo
|
|||||||
* ``pause`` state is already *paused*; or
|
* ``pause`` state is already *paused*; or
|
||||||
* cluster contains no accessible members.
|
* cluster contains no accessible members.
|
||||||
"""
|
"""
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(config, cluster_name, group)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
if global_config.from_cluster(cluster).is_paused == paused:
|
if get_global_config(cluster).is_paused == paused:
|
||||||
raise PatroniCtlException('Cluster is {0} paused'.format(paused and 'already' or 'not'))
|
raise PatroniCtlException('Cluster is {0} paused'.format(paused and 'already' or 'not'))
|
||||||
|
|
||||||
for member in get_all_members_leader_first(cluster):
|
for member in get_all_members_leader_first(cluster):
|
||||||
@@ -1821,33 +1821,37 @@ def toggle_pause(cluster_name: str, group: Optional[int], paused: bool, wait: bo
|
|||||||
@ctl.command('pause', help='Disable auto failover')
|
@ctl.command('pause', help='Disable auto failover')
|
||||||
@arg_cluster_name
|
@arg_cluster_name
|
||||||
@option_default_citus_group
|
@option_default_citus_group
|
||||||
|
@click.pass_obj
|
||||||
@click.option('--wait', help='Wait until pause is applied on all nodes', is_flag=True)
|
@click.option('--wait', help='Wait until pause is applied on all nodes', is_flag=True)
|
||||||
def pause(cluster_name: str, group: Optional[int], wait: bool) -> None:
|
def pause(obj: Dict[str, Any], cluster_name: str, group: Optional[int], wait: bool) -> None:
|
||||||
"""Process ``pause`` command of ``patronictl`` utility.
|
"""Process ``pause`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Put the cluster in maintenance mode.
|
Put the cluster in maintenance mode.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should pause. Refer to the module note for more details.
|
:param group: filter which Citus group we should pause. Refer to the module note for more details.
|
||||||
:param wait: ``True`` if it should block until the operation is finished or ``false`` for returning immediately.
|
:param wait: ``True`` if it should block until the operation is finished or ``false`` for returning immediately.
|
||||||
"""
|
"""
|
||||||
return toggle_pause(cluster_name, group, True, wait)
|
return toggle_pause(obj, cluster_name, group, True, wait)
|
||||||
|
|
||||||
|
|
||||||
@ctl.command('resume', help='Resume auto failover')
|
@ctl.command('resume', help='Resume auto failover')
|
||||||
@arg_cluster_name
|
@arg_cluster_name
|
||||||
@option_default_citus_group
|
@option_default_citus_group
|
||||||
@click.option('--wait', help='Wait until pause is cleared on all nodes', is_flag=True)
|
@click.option('--wait', help='Wait until pause is cleared on all nodes', is_flag=True)
|
||||||
def resume(cluster_name: str, group: Optional[int], wait: bool) -> None:
|
@click.pass_obj
|
||||||
|
def resume(obj: Dict[str, Any], cluster_name: str, group: Optional[int], wait: bool) -> None:
|
||||||
"""Process ``unpause`` command of ``patronictl`` utility.
|
"""Process ``unpause`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Put the cluster out of maintenance mode.
|
Put the cluster out of maintenance mode.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should unpause. Refer to the module note for more details.
|
:param group: filter which Citus group we should unpause. Refer to the module note for more details.
|
||||||
:param wait: ``True`` if it should block until the operation is finished or ``false`` for returning immediately.
|
:param wait: ``True`` if it should block until the operation is finished or ``false`` for returning immediately.
|
||||||
"""
|
"""
|
||||||
return toggle_pause(cluster_name, group, False, wait)
|
return toggle_pause(obj, cluster_name, group, False, wait)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -2079,12 +2083,15 @@ def invoke_editor(before_editing: str, cluster_name: str) -> Tuple[str, Dict[str
|
|||||||
@click.option('--replace', 'replace_filename', help='Apply configuration from file, replacing existing configuration.'
|
@click.option('--replace', 'replace_filename', help='Apply configuration from file, replacing existing configuration.'
|
||||||
' Use - for stdin.')
|
' Use - for stdin.')
|
||||||
@option_force
|
@option_force
|
||||||
def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: bool, kvpairs: List[str],
|
@click.pass_obj
|
||||||
pgkvpairs: List[str], apply_filename: Optional[str], replace_filename: Optional[str]) -> None:
|
def edit_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
|
||||||
|
force: bool, quiet: bool, kvpairs: List[str], pgkvpairs: List[str],
|
||||||
|
apply_filename: Optional[str], replace_filename: Optional[str]) -> None:
|
||||||
"""Process ``edit-config`` command of ``patronictl`` utility.
|
"""Process ``edit-config`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Update or replace Patroni configuration in the DCS.
|
Update or replace Patroni configuration in the DCS.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group configuration we should edit. Refer to the module note for more details.
|
:param group: filter which Citus group configuration we should edit. Refer to the module note for more details.
|
||||||
:param force: if ``True`` apply config changes without asking for confirmations.
|
:param force: if ``True`` apply config changes without asking for confirmations.
|
||||||
@@ -2101,7 +2108,7 @@ def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: boo
|
|||||||
* Configuration is absent from DCS; or
|
* Configuration is absent from DCS; or
|
||||||
* Detected a concurrent modification of the configuration in the DCS.
|
* Detected a concurrent modification of the configuration in the DCS.
|
||||||
"""
|
"""
|
||||||
dcs = get_dcs(cluster_name, group)
|
dcs = get_dcs(obj, cluster_name, group)
|
||||||
cluster = dcs.get_cluster()
|
cluster = dcs.get_cluster()
|
||||||
|
|
||||||
if not cluster.config:
|
if not cluster.config:
|
||||||
@@ -2139,7 +2146,7 @@ def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: boo
|
|||||||
return
|
return
|
||||||
|
|
||||||
if force or click.confirm('Apply these changes?'):
|
if force or click.confirm('Apply these changes?'):
|
||||||
if not dcs.set_config_value(json.dumps(changed_data, separators=(',', ':')), cluster.config.version):
|
if not dcs.set_config_value(json.dumps(changed_data), cluster.config.version):
|
||||||
raise PatroniCtlException("Config modification aborted due to concurrent changes")
|
raise PatroniCtlException("Config modification aborted due to concurrent changes")
|
||||||
click.echo("Configuration changed")
|
click.echo("Configuration changed")
|
||||||
|
|
||||||
@@ -2147,15 +2154,17 @@ def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: boo
|
|||||||
@ctl.command('show-config', help="Show cluster configuration")
|
@ctl.command('show-config', help="Show cluster configuration")
|
||||||
@arg_cluster_name
|
@arg_cluster_name
|
||||||
@option_default_citus_group
|
@option_default_citus_group
|
||||||
def show_config(cluster_name: str, group: Optional[int]) -> None:
|
@click.pass_obj
|
||||||
|
def show_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int]) -> None:
|
||||||
"""Process ``show-config`` command of ``patronictl`` utility.
|
"""Process ``show-config`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Show Patroni configuration stored in the DCS.
|
Show Patroni configuration stored in the DCS.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group configuration we should show. Refer to the module note for more details.
|
:param group: filter which Citus group configuration we should show. Refer to the module note for more details.
|
||||||
"""
|
"""
|
||||||
cluster = get_dcs(cluster_name, group).get_cluster()
|
cluster = get_dcs(obj, cluster_name, group).get_cluster()
|
||||||
if cluster.config:
|
if cluster.config:
|
||||||
click.echo(format_config_for_editing(cluster.config.data))
|
click.echo(format_config_for_editing(cluster.config.data))
|
||||||
|
|
||||||
@@ -2164,7 +2173,8 @@ def show_config(cluster_name: str, group: Optional[int]) -> None:
|
|||||||
@click.argument('cluster_name', required=False)
|
@click.argument('cluster_name', required=False)
|
||||||
@click.argument('member_names', nargs=-1)
|
@click.argument('member_names', nargs=-1)
|
||||||
@option_citus_group
|
@option_citus_group
|
||||||
def version(cluster_name: str, group: Optional[int], member_names: List[str]) -> None:
|
@click.pass_obj
|
||||||
|
def version(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member_names: List[str]) -> None:
|
||||||
"""Process ``version`` command of ``patronictl`` utility.
|
"""Process ``version`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Show version of:
|
Show version of:
|
||||||
@@ -2172,6 +2182,7 @@ def version(cluster_name: str, group: Optional[int], member_names: List[str]) ->
|
|||||||
* ``patroni`` on all members of the cluster;
|
* ``patroni`` on all members of the cluster;
|
||||||
* ``PostgreSQL`` on all members of the cluster.
|
* ``PostgreSQL`` on all members of the cluster.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should get members from. Refer to the module note for more details.
|
:param group: filter which Citus group we should get members from. Refer to the module note for more details.
|
||||||
:param member_names: filter which members we should get version information from.
|
:param member_names: filter which members we should get version information from.
|
||||||
@@ -2182,8 +2193,8 @@ def version(cluster_name: str, group: Optional[int], member_names: List[str]) ->
|
|||||||
return
|
return
|
||||||
|
|
||||||
click.echo("")
|
click.echo("")
|
||||||
cluster = get_dcs(cluster_name, group).get_cluster()
|
cluster = get_dcs(obj, cluster_name, group).get_cluster()
|
||||||
for m in get_all_members(cluster, group, 'any'):
|
for m in get_all_members(obj, cluster, group, 'any'):
|
||||||
if m.api_url:
|
if m.api_url:
|
||||||
if not member_names or m.name in member_names:
|
if not member_names or m.name in member_names:
|
||||||
try:
|
try:
|
||||||
@@ -2201,7 +2212,8 @@ def version(cluster_name: str, group: Optional[int], member_names: List[str]) ->
|
|||||||
@arg_cluster_name
|
@arg_cluster_name
|
||||||
@option_default_citus_group
|
@option_default_citus_group
|
||||||
@option_format
|
@option_format
|
||||||
def history(cluster_name: str, group: Optional[int], fmt: str) -> None:
|
@click.pass_obj
|
||||||
|
def history(obj: Dict[str, Any], cluster_name: str, group: Optional[int], fmt: str) -> None:
|
||||||
"""Process ``history`` command of ``patronictl`` utility.
|
"""Process ``history`` command of ``patronictl`` utility.
|
||||||
|
|
||||||
Show the history of failover/switchover events in the cluster.
|
Show the history of failover/switchover events in the cluster.
|
||||||
@@ -2213,11 +2225,12 @@ def history(cluster_name: str, group: Optional[int], fmt: str) -> None:
|
|||||||
* ``Timestamp``: timestamp when the event occurred;
|
* ``Timestamp``: timestamp when the event occurred;
|
||||||
* ``New Leader``: the Postgres node that was promoted during the event.
|
* ``New Leader``: the Postgres node that was promoted during the event.
|
||||||
|
|
||||||
|
:param obj: Patroni configuration.
|
||||||
:param cluster_name: name of the Patroni cluster.
|
:param cluster_name: name of the Patroni cluster.
|
||||||
:param group: filter which Citus group we should get events from. Refer to the module note for more details.
|
:param group: filter which Citus group we should get events from. Refer to the module note for more details.
|
||||||
:param fmt: the output table printing format. See :func:`print_output` for available options.
|
:param fmt: the output table printing format. See :func:`print_output` for available options.
|
||||||
"""
|
"""
|
||||||
cluster = get_dcs(cluster_name, group).get_cluster()
|
cluster = get_dcs(obj, cluster_name, group).get_cluster()
|
||||||
cluster_history = cluster.history.lines if cluster.history else []
|
cluster_history = cluster.history.lines if cluster.history else []
|
||||||
history: List[List[Any]] = list(map(list, cluster_history))
|
history: List[List[Any]] = list(map(list, cluster_history))
|
||||||
table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader']
|
table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader']
|
||||||
|
|||||||
+225
-173
@@ -1,22 +1,26 @@
|
|||||||
"""Abstract classes for Distributed Configuration Store."""
|
"""Abstract classes for Distributed Configuration Store."""
|
||||||
import abc
|
import abc
|
||||||
import datetime
|
import datetime
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import pkgutil
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from random import randint
|
from random import randint
|
||||||
from threading import Event, Lock
|
from threading import Event, Lock
|
||||||
from typing import Any, Callable, Collection, Dict, Iterator, List, \
|
from types import ModuleType
|
||||||
NamedTuple, Optional, Tuple, Type, TYPE_CHECKING, Union
|
from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Set, Tuple, Union, TYPE_CHECKING, \
|
||||||
|
Type, Iterator
|
||||||
from urllib.parse import urlparse, urlunparse, parse_qsl
|
from urllib.parse import urlparse, urlunparse, parse_qsl
|
||||||
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
|
||||||
from .. import global_config
|
|
||||||
from ..dynamic_loader import iter_classes, iter_modules
|
|
||||||
from ..exceptions import PatroniFatalException
|
from ..exceptions import PatroniFatalException
|
||||||
from ..utils import deep_compare, uri
|
from ..utils import deep_compare, uri
|
||||||
from ..tags import Tags
|
from ..tags import Tags
|
||||||
@@ -24,10 +28,10 @@ from ..utils import parse_int
|
|||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..postgresql import Postgresql
|
|
||||||
from ..postgresql.mpp import AbstractMPP
|
|
||||||
|
|
||||||
SLOT_ADVANCE_AVAILABLE_VERSION = 110000
|
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}$')
|
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -83,9 +87,28 @@ def parse_connection_string(value: str) -> Tuple[str, Union[str, None]]:
|
|||||||
def dcs_modules() -> List[str]:
|
def dcs_modules() -> List[str]:
|
||||||
"""Get names of DCS modules, depending on execution environment.
|
"""Get names of DCS modules, depending on execution environment.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
If being packaged with PyInstaller, modules aren't discoverable dynamically by scanning source directory because
|
||||||
|
:class:`importlib.machinery.FrozenImporter` doesn't implement :func:`iter_modules`. But it is still possible to
|
||||||
|
find all potential DCS modules by iterating through ``toc``, which contains list of all "frozen" resources.
|
||||||
|
|
||||||
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``.
|
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``.
|
||||||
"""
|
"""
|
||||||
return iter_modules(__package__)
|
dcs_dirname = os.path.dirname(__file__)
|
||||||
|
module_prefix = __package__ + '.'
|
||||||
|
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
toc: Set[str] = set()
|
||||||
|
# dcs_dirname may contain a dot, which causes pkgutil.iter_importers()
|
||||||
|
# to misinterpret the path as a package name. This can be avoided
|
||||||
|
# altogether by not passing a path at all, because PyInstaller's
|
||||||
|
# FrozenImporter is a singleton and registered as top-level finder.
|
||||||
|
for importer in pkgutil.iter_importers():
|
||||||
|
if hasattr(importer, 'toc'):
|
||||||
|
toc |= getattr(importer, 'toc')
|
||||||
|
return [module for module in toc if module.startswith(module_prefix) and module.count('.') == 2]
|
||||||
|
|
||||||
|
return [module_prefix + name for _, name, is_pkg in pkgutil.iter_modules([dcs_dirname]) if not is_pkg]
|
||||||
|
|
||||||
|
|
||||||
def iter_dcs_classes(
|
def iter_dcs_classes(
|
||||||
@@ -99,16 +122,44 @@ def iter_dcs_classes(
|
|||||||
:param config: configuration information with possible DCS names as keys. If given, only attempt to import DCS
|
:param config: configuration information with possible DCS names as keys. If given, only attempt to import DCS
|
||||||
modules defined in the configuration. Else, if ``None``, attempt to import any supported DCS module.
|
modules defined in the configuration. Else, if ``None``, attempt to import any supported DCS module.
|
||||||
|
|
||||||
:returns: an iterator of tuples, each containing the module ``name`` and the imported DCS class object.
|
:yields: a tuple containing the module ``name`` and the imported DCS class object.
|
||||||
"""
|
"""
|
||||||
return iter_classes(__package__, AbstractDCS, config)
|
for mod_name in dcs_modules():
|
||||||
|
name = mod_name.rpartition('.')[2]
|
||||||
|
if config is None or name in config:
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(mod_name)
|
||||||
|
dcs_module = find_dcs_class_in_module(module)
|
||||||
|
if dcs_module:
|
||||||
|
yield name, dcs_module
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.log(logging.DEBUG if config is not None else logging.INFO,
|
||||||
|
'Failed to import %s', mod_name)
|
||||||
|
|
||||||
|
|
||||||
|
def find_dcs_class_in_module(module: ModuleType) -> Optional[Type['AbstractDCS']]:
|
||||||
|
"""Try to find the implementation of :class:`AbstractDCS` interface in *module* matching the *module* name.
|
||||||
|
|
||||||
|
:param module: Imported DCS module.
|
||||||
|
|
||||||
|
:returns: class with a name matching the name of *module* that implements :class:`AbstractDCS` or ``None`` if not
|
||||||
|
found.
|
||||||
|
"""
|
||||||
|
module_name = module.__name__.rpartition('.')[2]
|
||||||
|
return next(
|
||||||
|
(obj for obj_name, obj in module.__dict__.items()
|
||||||
|
if (obj_name.lower() == module_name
|
||||||
|
and inspect.isclass(obj) and issubclass(obj, AbstractDCS))),
|
||||||
|
None)
|
||||||
|
|
||||||
|
|
||||||
def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
|
def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
|
||||||
"""Attempt to load a Distributed Configuration Store from known available implementations.
|
"""Attempt to load a Distributed Configuration Store from known available implementations.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Using the list of available DCS classes returned by :func:`iter_classes` attempt to dynamically
|
Using the list of available DCS classes returned by :func:`iter_dcs_classes` attempt to dynamically
|
||||||
instantiate the class that implements a DCS using the abstract class :class:`AbstractDCS`.
|
instantiate the class that implements a DCS using the abstract class :class:`AbstractDCS`.
|
||||||
|
|
||||||
Basic top-level configuration parameters retrieved from *config* are propagated to the DCS specific config
|
Basic top-level configuration parameters retrieved from *config* are propagated to the DCS specific config
|
||||||
@@ -129,13 +180,14 @@ def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
|
|||||||
p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
|
p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
|
||||||
'patronictl', 'ttl', 'retry_timeout')
|
'patronictl', 'ttl', 'retry_timeout')
|
||||||
if p in config})
|
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
|
raise PatroniFatalException(
|
||||||
return dcs_class(config[name], get_mpp(config))
|
f"Can not find suitable configuration of distributed configuration store\n"
|
||||||
|
f"Available implementations: {', '.join(sorted([n for n, _ in iter_dcs_classes()]))}")
|
||||||
available_implementations = ', '.join(sorted([n for n, _ in iter_dcs_classes()]))
|
|
||||||
raise PatroniFatalException("Can not find suitable configuration of distributed configuration store\n"
|
|
||||||
f"Available implementations: {available_implementations}")
|
|
||||||
|
|
||||||
|
|
||||||
_Version = Union[int, str]
|
_Version = Union[int, str]
|
||||||
@@ -538,6 +590,24 @@ class ClusterConfig(NamedTuple):
|
|||||||
modify_version = 0
|
modify_version = 0
|
||||||
return ClusterConfig(version, data, version if modify_version is None else modify_version)
|
return ClusterConfig(version, data, version if modify_version is None else modify_version)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def permanent_slots(self) -> Dict[str, Any]:
|
||||||
|
"""Dictionary of permanent slots information looked up from :attr:`~ClusterConfig.data`."""
|
||||||
|
return (self.data.get('permanent_replication_slots')
|
||||||
|
or self.data.get('permanent_slots')
|
||||||
|
or self.data.get('slots')
|
||||||
|
or {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ignore_slots_matchers(self) -> List[Dict[str, Any]]:
|
||||||
|
"""The value for ``ignore_slots`` from :attr:`~ClusterConfig.data` if defined or an empty list."""
|
||||||
|
return self.data.get('ignore_slots') or []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_timelines_history(self) -> int:
|
||||||
|
"""The value for ``max_timelines_history`` from :attr:`~ClusterConfig.data` if defined or ``0``."""
|
||||||
|
return self.data.get('max_timelines_history', 0)
|
||||||
|
|
||||||
|
|
||||||
class SyncState(NamedTuple):
|
class SyncState(NamedTuple):
|
||||||
"""Immutable object (namedtuple) which represents last observed synchronous replication state.
|
"""Immutable object (namedtuple) which represents last observed synchronous replication state.
|
||||||
@@ -545,22 +615,18 @@ class SyncState(NamedTuple):
|
|||||||
:ivar version: modification version of a synchronization key in a Configuration Store.
|
:ivar version: modification version of a synchronization key in a Configuration Store.
|
||||||
:ivar leader: reference to member that was leader.
|
:ivar leader: reference to member that was leader.
|
||||||
:ivar sync_standby: synchronous standby list (comma delimited) which are last synchronized to leader.
|
:ivar sync_standby: synchronous standby list (comma delimited) which are last synchronized to leader.
|
||||||
:ivar quorum: if the node from :attr:`~SyncState.sync_standby` list is doing a leader race it should
|
|
||||||
see at least :attr:`~SyncState.quorum` other nodes from the
|
|
||||||
:attr:`~SyncState.sync_standby` + :attr:`~SyncState.leader` list.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
version: Optional[_Version]
|
version: Optional[_Version]
|
||||||
leader: Optional[str]
|
leader: Optional[str]
|
||||||
sync_standby: Optional[str]
|
sync_standby: Optional[str]
|
||||||
quorum: int
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_node(version: Optional[_Version], value: Union[str, Dict[str, Any], None]) -> 'SyncState':
|
def from_node(version: Optional[_Version], value: Union[str, Dict[str, Any], None]) -> 'SyncState':
|
||||||
"""Factory method to parse *value* as synchronisation state information.
|
"""Factory method to parse *value* as synchronisation state information.
|
||||||
|
|
||||||
:param version: optional *version* number for the object.
|
:param version: optional *version* number for the object.
|
||||||
:param value: (optionally JSON serialised) synchronisation state information
|
:param value: (optionally JSON serialised) sychronisation state information
|
||||||
|
|
||||||
:returns: constructed :class:`SyncState` object.
|
:returns: constructed :class:`SyncState` object.
|
||||||
|
|
||||||
@@ -588,9 +654,7 @@ class SyncState(NamedTuple):
|
|||||||
if value and isinstance(value, str):
|
if value and isinstance(value, str):
|
||||||
value = json.loads(value)
|
value = json.loads(value)
|
||||||
assert isinstance(value, dict)
|
assert isinstance(value, dict)
|
||||||
leader = value.get('leader')
|
return SyncState(version, value.get('leader'), value.get('sync_standby'))
|
||||||
quorum = value.get('quorum')
|
|
||||||
return SyncState(version, leader, value.get('sync_standby'), int(quorum) if leader and quorum else 0)
|
|
||||||
except (AssertionError, TypeError, ValueError):
|
except (AssertionError, TypeError, ValueError):
|
||||||
return SyncState.empty(version)
|
return SyncState.empty(version)
|
||||||
|
|
||||||
@@ -602,7 +666,7 @@ class SyncState(NamedTuple):
|
|||||||
|
|
||||||
:returns: empty synchronisation state object.
|
:returns: empty synchronisation state object.
|
||||||
"""
|
"""
|
||||||
return SyncState(version, None, None, 0)
|
return SyncState(version, None, None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
@@ -620,17 +684,10 @@ class SyncState(NamedTuple):
|
|||||||
return list(filter(lambda a: a, [s.strip() for s in value.split(',')]))
|
return list(filter(lambda a: a, [s.strip() for s in value.split(',')]))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def voters(self) -> List[str]:
|
def members(self) -> List[str]:
|
||||||
""":attr:`~SyncState.sync_standby` as list or an empty list if undefined or object considered ``empty``."""
|
""":attr:`~SyncState.sync_standby` as list or an empty list if undefined or object considered ``empty``."""
|
||||||
return self._str_to_list(self.sync_standby) if not self.is_empty and self.sync_standby else []
|
return self._str_to_list(self.sync_standby) if not self.is_empty and self.sync_standby else []
|
||||||
|
|
||||||
@property
|
|
||||||
def members(self) -> List[str]:
|
|
||||||
""":attr:`~SyncState.sync_standby` and :attr:`~SyncState.leader` as list
|
|
||||||
or an empty list if object considered ``empty``.
|
|
||||||
"""
|
|
||||||
return [] if not self.leader else [self.leader] + self.voters
|
|
||||||
|
|
||||||
def matches(self, name: Optional[str], check_leader: bool = False) -> bool:
|
def matches(self, name: Optional[str], check_leader: bool = False) -> bool:
|
||||||
"""Checks if node is presented in the /sync state.
|
"""Checks if node is presented in the /sync state.
|
||||||
|
|
||||||
@@ -644,7 +701,7 @@ class SyncState(NamedTuple):
|
|||||||
the sync state.
|
the sync state.
|
||||||
|
|
||||||
:Example:
|
:Example:
|
||||||
>>> s = SyncState(1, 'foo', 'bar,zoo', 0)
|
>>> s = SyncState(1, 'foo', 'bar,zoo')
|
||||||
|
|
||||||
>>> s.matches('foo')
|
>>> s.matches('foo')
|
||||||
False
|
False
|
||||||
@@ -795,7 +852,7 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
('history', Optional[TimelineHistory]),
|
('history', Optional[TimelineHistory]),
|
||||||
('failsafe', Optional[Dict[str, str]]),
|
('failsafe', Optional[Dict[str, str]]),
|
||||||
('workers', Dict[int, 'Cluster'])])):
|
('workers', Dict[int, 'Cluster'])])):
|
||||||
"""Immutable object (namedtuple) which represents PostgreSQL or MPP cluster.
|
"""Immutable object (namedtuple) which represents PostgreSQL or Citus cluster.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
We are using an old-style attribute declaration here because otherwise it is not possible to override `__new__`
|
We are using an old-style attribute declaration here because otherwise it is not possible to override `__new__`
|
||||||
@@ -812,8 +869,8 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
|
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
|
||||||
:ivar history: reference to `TimelineHistory` object.
|
: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 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 MPP cluster, optional. Each key representing the group and the
|
:ivar workers: dictionary of workers of the Citus cluster, optional. Each key is an :class:`int` representing
|
||||||
corresponding value is a :class:`Cluster` instance.
|
the group, and the corresponding value is a :class:`Cluster` instance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __new__(cls, *args: Any, **kwargs: Any):
|
def __new__(cls, *args: Any, **kwargs: Any):
|
||||||
@@ -939,7 +996,7 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
@property
|
@property
|
||||||
def __permanent_slots(self) -> Dict[str, Union[Dict[str, Any], Any]]:
|
def __permanent_slots(self) -> Dict[str, Union[Dict[str, Any], Any]]:
|
||||||
"""Dictionary of permanent replication slots with their known LSN."""
|
"""Dictionary of permanent replication slots with their known LSN."""
|
||||||
ret: Dict[str, Union[Dict[str, Any], Any]] = global_config.permanent_slots
|
ret: Dict[str, Union[Dict[str, Any], Any]] = deepcopy(self.config.permanent_slots if self.config else {})
|
||||||
|
|
||||||
members: Dict[str, int] = {slot_name_from_member_name(m.name): m.lsn or 0 for m in self.members}
|
members: Dict[str, int] = {slot_name_from_member_name(m.name): m.lsn or 0 for m in self.members}
|
||||||
slots: Dict[str, int] = {k: parse_int(v) or 0 for k, v in (self.slots or {}).items()}
|
slots: Dict[str, int] = {k: parse_int(v) or 0 for k, v in (self.slots or {}).items()}
|
||||||
@@ -968,29 +1025,36 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
"""Dictionary of permanent ``logical`` replication slots."""
|
"""Dictionary of permanent ``logical`` replication slots."""
|
||||||
return {name: value for name, value in self.__permanent_slots.items() if self.is_logical_slot(value)}
|
return {name: value for name, value in self.__permanent_slots.items() if self.is_logical_slot(value)}
|
||||||
|
|
||||||
def get_replication_slots(self, postgresql: 'Postgresql', member: Tags, *,
|
@property
|
||||||
role: Optional[str] = None, show_error: bool = False) -> Dict[str, Dict[str, Any]]:
|
def use_slots(self) -> bool:
|
||||||
|
"""``True`` if cluster is configured to use replication slots."""
|
||||||
|
return bool(self.config and (self.config.data.get('postgresql') or {}).get('use_slots', True))
|
||||||
|
|
||||||
|
def get_replication_slots(self, my_name: str, role: str, nofailover: bool, major_version: int, *,
|
||||||
|
is_standby_cluster: bool = False, show_error: bool = False) -> Dict[str, Dict[str, Any]]:
|
||||||
"""Lookup configured slot names in the DCS, report issues found and merge with permanent slots.
|
"""Lookup configured slot names in the DCS, report issues found and merge with permanent slots.
|
||||||
|
|
||||||
Will log an error if:
|
Will log an error if:
|
||||||
|
|
||||||
* Any logical slots are disabled, due to version compatibility, and *show_error* is ``True``.
|
* Any logical slots are disabled, due to version compatibility, and *show_error* is ``True``.
|
||||||
|
|
||||||
:param postgresql: reference to :class:`Postgresql` object.
|
:param my_name: name of this node.
|
||||||
:param member: reference to an object implementing :class:`Tags` interface.
|
:param role: role of this node.
|
||||||
:param role: role of the node, if not set will be taken from *postgresql*.
|
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
|
||||||
|
:param major_version: postgresql major version.
|
||||||
|
:param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from
|
||||||
|
the outside because we want to protect from the ``/config`` key removal.
|
||||||
:param show_error: if ``True`` report error if any disabled logical slots or conflicting slot names are found.
|
:param show_error: if ``True`` report error if any disabled logical slots or conflicting slot names are found.
|
||||||
|
|
||||||
:returns: final dictionary of slot names, after merging with permanent slots and performing sanity checks.
|
:returns: final dictionary of slot names, after merging with permanent slots and performing sanity checks.
|
||||||
"""
|
"""
|
||||||
name = member.name if isinstance(member, Member) else postgresql.name
|
slots: Dict[str, Dict[str, str]] = self._get_members_slots(my_name, role)
|
||||||
role = role or postgresql.role
|
permanent_slots: Dict[str, Any] = self._get_permanent_slots(is_standby_cluster=is_standby_cluster,
|
||||||
|
role=role, nofailover=nofailover,
|
||||||
slots: Dict[str, Dict[str, str]] = self._get_members_slots(name, role)
|
major_version=major_version)
|
||||||
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
|
|
||||||
|
|
||||||
disabled_permanent_logical_slots: List[str] = self._merge_permanent_slots(
|
disabled_permanent_logical_slots: List[str] = self._merge_permanent_slots(
|
||||||
slots, permanent_slots, name, postgresql.major_version)
|
slots, permanent_slots, my_name, major_version)
|
||||||
|
|
||||||
if disabled_permanent_logical_slots and show_error:
|
if disabled_permanent_logical_slots and show_error:
|
||||||
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
|
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
|
||||||
@@ -998,7 +1062,7 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
|
|
||||||
return slots
|
return slots
|
||||||
|
|
||||||
def _merge_permanent_slots(self, slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], name: str,
|
def _merge_permanent_slots(self, slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], my_name: str,
|
||||||
major_version: int) -> List[str]:
|
major_version: int) -> List[str]:
|
||||||
"""Merge replication *slots* for members with *permanent_slots*.
|
"""Merge replication *slots* for members with *permanent_slots*.
|
||||||
|
|
||||||
@@ -1008,7 +1072,7 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
Type is assumed to be ``physical`` if there are no attributes stored as the slot value.
|
Type is assumed to be ``physical`` if there are no attributes stored as the slot value.
|
||||||
|
|
||||||
:param slots: Slot names with existing attributes if known.
|
:param slots: Slot names with existing attributes if known.
|
||||||
:param name: name of this node.
|
:param my_name: name of this node.
|
||||||
:param permanent_slots: dictionary containing slot name key and slot information values.
|
:param permanent_slots: dictionary containing slot name key and slot information values.
|
||||||
:param major_version: postgresql major version.
|
:param major_version: postgresql major version.
|
||||||
|
|
||||||
@@ -1016,9 +1080,9 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
"""
|
"""
|
||||||
disabled_permanent_logical_slots: List[str] = []
|
disabled_permanent_logical_slots: List[str] = []
|
||||||
|
|
||||||
for slot_name, value in permanent_slots.items():
|
for name, value in permanent_slots.items():
|
||||||
if not slot_name_re.match(slot_name):
|
if not slot_name_re.match(name):
|
||||||
logger.error("Invalid permanent replication slot name '%s'", slot_name)
|
logger.error("Invalid permanent replication slot name '%s'", name)
|
||||||
logger.error("Slot name may only contain lower case letters, numbers, and the underscore chars")
|
logger.error("Slot name may only contain lower case letters, numbers, and the underscore chars")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1029,54 +1093,55 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
|
|
||||||
if value['type'] == 'physical':
|
if value['type'] == 'physical':
|
||||||
# Don't try to create permanent physical replication slot for yourself
|
# Don't try to create permanent physical replication slot for yourself
|
||||||
if slot_name != slot_name_from_member_name(name):
|
if name != slot_name_from_member_name(my_name):
|
||||||
slots[slot_name] = value
|
slots[name] = value
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.is_logical_slot(value):
|
if self.is_logical_slot(value):
|
||||||
if major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
|
if major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
|
||||||
disabled_permanent_logical_slots.append(slot_name)
|
disabled_permanent_logical_slots.append(name)
|
||||||
elif slot_name in slots:
|
elif name in slots:
|
||||||
logger.error("Permanent logical replication slot {'%s': %s} is conflicting with"
|
logger.error("Permanent logical replication slot {'%s': %s} is conflicting with"
|
||||||
" physical replication slot for cluster member", slot_name, value)
|
" physical replication slot for cluster member", name, value)
|
||||||
else:
|
else:
|
||||||
slots[slot_name] = value
|
slots[name] = value
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.error("Bad value for slot '%s' in permanent_slots: %s", slot_name, permanent_slots[slot_name])
|
logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])
|
||||||
return disabled_permanent_logical_slots
|
return disabled_permanent_logical_slots
|
||||||
|
|
||||||
def _get_permanent_slots(self, postgresql: 'Postgresql', tags: Tags, role: str) -> Dict[str, Any]:
|
def _get_permanent_slots(self, *, is_standby_cluster: bool, role: str,
|
||||||
|
nofailover: bool, major_version: int) -> Dict[str, Any]:
|
||||||
"""Get configured permanent replication slots.
|
"""Get configured permanent replication slots.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Permanent replication slots are only considered if ``use_slots`` configuration is enabled.
|
Permanent replication slots are only considered if ``use_slots`` configuration is enabled.
|
||||||
A node that is not supposed to become a leader (*nofailover*) will not have permanent replication slots.
|
A node that is not supposed to become a leader (*nofailover*) will not have permanent replication slots.
|
||||||
Also node with disabled streaming (*nostream*) and its cascading followers must not have permanent
|
|
||||||
logical slots due to lack of feedback from node to primary, which makes them unsafe to use.
|
|
||||||
|
|
||||||
In a standby cluster we only support physical replication slots.
|
In a standby cluster we only support physical replication slots.
|
||||||
|
|
||||||
The returned dictionary for a non-standby cluster always contains permanent logical replication slots in
|
The returned dictionary for a non-standby cluster always contains permanent logical replication slots in
|
||||||
order to show a warning if they are not supported by PostgreSQL before v11.
|
order to show a warning if they are not supported by PostgreSQL before v11.
|
||||||
|
|
||||||
:param postgresql: reference to :class:`Postgresql` object.
|
:param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from
|
||||||
:param tags: reference to an object implementing :class:`Tags` interface.
|
the outside because we want to protect from the ``/config`` key removal.
|
||||||
:param role: role of the node -- ``primary``, ``standby_leader`` or ``replica``.
|
:param role: role of this node -- ``primary``, ``standby_leader`` or ``replica``.
|
||||||
|
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
|
||||||
|
:param major_version: postgresql major version.
|
||||||
|
|
||||||
:returns: dictionary of permanent slot names mapped to attributes.
|
:returns: dictionary of permanent slot names mapped to attributes.
|
||||||
"""
|
"""
|
||||||
if not global_config.use_slots or tags.nofailover:
|
if not self.use_slots or nofailover:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if global_config.is_standby_cluster or self.get_slot_name_on_primary(postgresql.name, tags) is None:
|
if is_standby_cluster:
|
||||||
return self.__permanent_physical_slots \
|
return self.__permanent_physical_slots \
|
||||||
if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {}
|
if major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {}
|
||||||
|
|
||||||
return self.__permanent_slots if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION\
|
return self.__permanent_slots if major_version >= SLOT_ADVANCE_AVAILABLE_VERSION\
|
||||||
or role in ('master', 'primary') else self.__permanent_logical_slots
|
or role in ('master', 'primary') else self.__permanent_logical_slots
|
||||||
|
|
||||||
def _get_members_slots(self, name: str, role: str) -> Dict[str, Dict[str, str]]:
|
def _get_members_slots(self, my_name: str, role: str) -> Dict[str, Dict[str, str]]:
|
||||||
"""Get physical replication slots configuration for members that sourcing from this node.
|
"""Get physical replication slots configuration for members that sourcing from this node.
|
||||||
|
|
||||||
If the ``replicatefrom`` tag is set on the member - we should not create the replication slot for it on
|
If the ``replicatefrom`` tag is set on the member - we should not create the replication slot for it on
|
||||||
@@ -1084,34 +1149,29 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
the ``replicatefrom`` destination member is currently not a member of the cluster (fallback to the
|
the ``replicatefrom`` destination member is currently not a member of the cluster (fallback to the
|
||||||
primary), or if ``replicatefrom`` destination member happens to be the current primary.
|
primary), or if ``replicatefrom`` destination member happens to be the current primary.
|
||||||
|
|
||||||
If the ``nostream`` tag is set on the member - we should not create the replication slot for it on
|
|
||||||
the current primary or any other member even if ``replicatefrom`` is set, because ``nostream`` disables
|
|
||||||
WAL streaming.
|
|
||||||
|
|
||||||
Will log an error if:
|
Will log an error if:
|
||||||
|
|
||||||
* Conflicting slot names between members are found
|
* Conflicting slot names between members are found
|
||||||
|
|
||||||
:param name: name of this node.
|
:param my_name: name of this node.
|
||||||
:param role: role of this node, if this is a ``primary`` or ``standby_leader`` return list of members
|
:param role: role of this node, if this is a ``primary`` or ``standby_leader`` return list of members
|
||||||
replicating from this node. If not then return a list of members replicating as cascaded
|
replicating from this node. If not then return a list of members replicating as cascaded
|
||||||
replicas from this node.
|
replicas from this node.
|
||||||
|
|
||||||
:returns: dictionary of physical replication slots that should exist on a given node.
|
:returns: dictionary of physical replication slots that should exist on a given node.
|
||||||
"""
|
"""
|
||||||
if not global_config.use_slots:
|
if not self.use_slots:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# we always want to exclude the member with our name from the list,
|
# we always want to exclude the member with our name from the list
|
||||||
# also exlude members with disabled WAL streaming
|
members = filter(lambda m: m.name != my_name, self.members)
|
||||||
members = filter(lambda m: m.name != name and not m.nostream, self.members)
|
|
||||||
|
|
||||||
if role in ('master', 'primary', 'standby_leader'):
|
if role in ('master', 'primary', 'standby_leader'):
|
||||||
members = [m for m in members if m.replicatefrom is None
|
members = [m for m in members if m.replicatefrom is None
|
||||||
or m.replicatefrom == name or not self.has_member(m.replicatefrom)]
|
or m.replicatefrom == my_name or not self.has_member(m.replicatefrom)]
|
||||||
else:
|
else:
|
||||||
# only manage slots for replicas that replicate from this one, except for the leader among them
|
# only manage slots for replicas that replicate from this one, except for the leader among them
|
||||||
members = [m for m in members if m.replicatefrom == name and m.name != self.leader_name]
|
members = [m for m in members if m.replicatefrom == my_name and m.name != self.leader_name]
|
||||||
|
|
||||||
slots = {slot_name_from_member_name(m.name): {'type': 'physical'} for m in members}
|
slots = {slot_name_from_member_name(m.name): {'type': 'physical'} for m in members}
|
||||||
if len(slots) < len(members):
|
if len(slots) < len(members):
|
||||||
@@ -1124,76 +1184,84 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
for k, v in slot_conflicts.items() if len(v) > 1))
|
for k, v in slot_conflicts.items() if len(v) > 1))
|
||||||
return slots
|
return slots
|
||||||
|
|
||||||
def has_permanent_slots(self, postgresql: 'Postgresql', member: Tags) -> bool:
|
def has_permanent_slots(self, my_name: str, *, is_standby_cluster: bool = False, nofailover: bool = False,
|
||||||
"""Check if our node has permanent replication slots configured.
|
major_version: int = SLOT_ADVANCE_AVAILABLE_VERSION) -> bool:
|
||||||
|
"""Check if the given member node has permanent replication slots configured.
|
||||||
|
|
||||||
:param postgresql: reference to :class:`Postgresql` object.
|
:param my_name: name of the member node to check.
|
||||||
:param member: reference to an object implementing :class:`Tags` interface for
|
:param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from
|
||||||
the node that we are checking permanent logical replication slots for.
|
the outside because we want to protect from the ``/config`` key removal.
|
||||||
|
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
|
||||||
|
:param major_version: postgresql major version.
|
||||||
|
|
||||||
:returns: ``True`` if there are permanent replication slots configured, otherwise ``False``.
|
:returns: ``True`` if there are permanent replication slots configured, otherwise ``False``.
|
||||||
"""
|
"""
|
||||||
role = 'replica'
|
role = 'replica'
|
||||||
members_slots: Dict[str, Dict[str, str]] = self._get_members_slots(postgresql.name, role)
|
members_slots: Dict[str, Dict[str, str]] = self._get_members_slots(my_name, role)
|
||||||
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
|
permanent_slots: Dict[str, Any] = self._get_permanent_slots(is_standby_cluster=is_standby_cluster,
|
||||||
|
role=role, nofailover=nofailover,
|
||||||
|
major_version=major_version)
|
||||||
slots = deepcopy(members_slots)
|
slots = deepcopy(members_slots)
|
||||||
self._merge_permanent_slots(slots, permanent_slots, postgresql.name, postgresql.major_version)
|
self._merge_permanent_slots(slots, permanent_slots, my_name, major_version)
|
||||||
return len(slots) > len(members_slots) or any(self.is_physical_slot(v) for v in permanent_slots.values())
|
return len(slots) > len(members_slots) or any(self.is_physical_slot(v) for v in permanent_slots.values())
|
||||||
|
|
||||||
def filter_permanent_slots(self, postgresql: 'Postgresql', slots: Dict[str, int]) -> Dict[str, int]:
|
def filter_permanent_slots(self, slots: Dict[str, int], is_standby_cluster: bool,
|
||||||
|
major_version: int) -> Dict[str, int]:
|
||||||
"""Filter out all non-permanent slots from provided *slots* dict.
|
"""Filter out all non-permanent slots from provided *slots* dict.
|
||||||
|
|
||||||
:param postgresql: reference to :class:`Postgresql` object.
|
:param slots: slot names with LSN values
|
||||||
:param slots: slot names with LSN values.
|
:param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from
|
||||||
|
the outside because we want to protect from the ``/config`` key removal.
|
||||||
|
:param major_version: postgresql major version.
|
||||||
|
|
||||||
:returns: a :class:`dict` object that contains only slots that are known to be permanent.
|
:returns: a :class:`dict` object that contains only slots that are known to be permanent.
|
||||||
"""
|
"""
|
||||||
if postgresql.major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
|
if major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
|
||||||
return {} # for legacy PostgreSQL we don't support permanent slots on standby nodes
|
return {} # for legacy PostgreSQL we don't support permanent slots on standby nodes
|
||||||
|
|
||||||
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, RemoteMember('', {}), 'replica')
|
permanent_slots: Dict[str, Any] = self._get_permanent_slots(is_standby_cluster=is_standby_cluster,
|
||||||
|
role='replica',
|
||||||
|
nofailover=False,
|
||||||
|
major_version=major_version)
|
||||||
members_slots = {slot_name_from_member_name(m.name) for m in self.members}
|
members_slots = {slot_name_from_member_name(m.name) for m in self.members}
|
||||||
|
|
||||||
return {name: value for name, value in slots.items() if name in permanent_slots
|
return {name: value for name, value in slots.items() if name in permanent_slots
|
||||||
and (self.is_physical_slot(permanent_slots[name])
|
and (self.is_physical_slot(permanent_slots[name])
|
||||||
or self.is_logical_slot(permanent_slots[name]) and name not in members_slots)}
|
or self.is_logical_slot(permanent_slots[name]) and name not in members_slots)}
|
||||||
|
|
||||||
def _has_permanent_logical_slots(self, postgresql: 'Postgresql', member: Tags) -> bool:
|
def _has_permanent_logical_slots(self, my_name: str, nofailover: bool) -> bool:
|
||||||
"""Check if the given member node has permanent ``logical`` replication slots configured.
|
"""Check if the given member node has permanent ``logical`` replication slots configured.
|
||||||
|
|
||||||
:param postgresql: reference to a :class:`Postgresql` object.
|
:param my_name: name of the member node to check.
|
||||||
:param member: reference to an object implementing :class:`Tags` interface for
|
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
|
||||||
the node that we are checking permanent logical replication slots for.
|
|
||||||
|
|
||||||
:returns: ``True`` if any detected replications slots are ``logical``, otherwise ``False``.
|
:returns: ``True`` if any detected replications slots are ``logical``, otherwise ``False``.
|
||||||
"""
|
"""
|
||||||
slots = self.get_replication_slots(postgresql, member, role='replica').values()
|
slots = self.get_replication_slots(my_name, 'replica', nofailover, SLOT_ADVANCE_AVAILABLE_VERSION).values()
|
||||||
return any(v for v in slots if v.get("type") == "logical")
|
return any(v for v in slots if v.get("type") == "logical")
|
||||||
|
|
||||||
def should_enforce_hot_standby_feedback(self, postgresql: 'Postgresql', member: Tags) -> bool:
|
def should_enforce_hot_standby_feedback(self, my_name: str, nofailover: bool) -> bool:
|
||||||
"""Determine whether ``hot_standby_feedback`` should be enabled for the given member.
|
"""Determine whether ``hot_standby_feedback`` should be enabled for the given member.
|
||||||
|
|
||||||
The ``hot_standby_feedback`` must be enabled if the current replica has ``logical`` slots,
|
The ``hot_standby_feedback`` must be enabled if the current replica has ``logical`` slots,
|
||||||
or it is working as a cascading replica for the other node that has ``logical`` slots.
|
or it is working as a cascading replica for the other node that has ``logical`` slots.
|
||||||
|
|
||||||
:param postgresql: reference to a :class:`Postgresql` object.
|
:param my_name: name of the member node to check.
|
||||||
:param member: reference to an object implementing :class:`Tags` interface for
|
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
|
||||||
the node that we are checking permanent logical replication slots for.
|
|
||||||
|
|
||||||
:returns: ``True`` if this node or any member replicating from this node has
|
:returns: ``True`` if this node or any member replicating from this node has
|
||||||
permanent logical slots, otherwise ``False``.
|
permanent logical slots, otherwise ``False``.
|
||||||
"""
|
"""
|
||||||
if self._has_permanent_logical_slots(postgresql, member):
|
if self._has_permanent_logical_slots(my_name, nofailover):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if global_config.use_slots:
|
if self.use_slots:
|
||||||
name = member.name if isinstance(member, Member) else postgresql.name
|
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader_name]
|
||||||
members = [m for m in self.members if m.replicatefrom == name and m.name != self.leader_name]
|
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover) for m in members)
|
||||||
return any(self.should_enforce_hot_standby_feedback(postgresql, m) for m in members)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_slot_name_on_primary(self, name: str, tags: Tags) -> Optional[str]:
|
def get_my_slot_name_on_primary(self, my_name: str, replicatefrom: Optional[str]) -> str:
|
||||||
"""Get the name of physical replication slot for this node on the primary.
|
"""Canonical slot name for physical replication.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
P <-- I <-- L
|
P <-- I <-- L
|
||||||
@@ -1201,16 +1269,14 @@ class Cluster(NamedTuple('Cluster',
|
|||||||
In case of cascading replication we have to check not our physical slot, but slot of the replica that
|
In case of cascading replication we have to check not our physical slot, but slot of the replica that
|
||||||
connects us to the primary.
|
connects us to the primary.
|
||||||
|
|
||||||
:param name: name of the member node to check.
|
:param my_name: the member node name that is replicating.
|
||||||
:param tags: reference to an object implementing :class:`Tags` interface.
|
:param replicatefrom: the Intermediate member name that is configured to replicate for cascading replication.
|
||||||
|
|
||||||
:returns: the slot name on the primary that is in use for physical replication on this node.
|
:returns: The slot name that is in use for physical replication on this no`de.
|
||||||
"""
|
"""
|
||||||
if tags.nostream:
|
m = self.get_member(replicatefrom, False) if replicatefrom else None
|
||||||
return None
|
return self.get_my_slot_name_on_primary(m.name, m.replicatefrom) \
|
||||||
replicatefrom = self.get_member(tags.replicatefrom, False) if tags.replicatefrom else None
|
if isinstance(m, Member) else slot_name_from_member_name(my_name)
|
||||||
return self.get_slot_name_on_primary(replicatefrom.name, replicatefrom) \
|
|
||||||
if isinstance(replicatefrom, Member) else slot_name_from_member_name(name)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timeline(self) -> int:
|
def timeline(self) -> int:
|
||||||
@@ -1285,11 +1351,11 @@ class AbstractDCS(abc.ABC):
|
|||||||
Functional methods that are critical in their timing, required to complete within ``retry_timeout`` period in order
|
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:
|
to prevent the DCS considered inaccessible, each perform construction of complex data objects:
|
||||||
|
|
||||||
* :meth:`~AbstractDCS._postgresql_cluster_loader`:
|
* :meth:`~AbstractDCS._cluster_loader`:
|
||||||
method which processes the structure of data stored in the DCS used to build the :class:`Cluster` object
|
method which processes the structure of data stored in the DCS used to build the :class:`Cluster` object
|
||||||
with all relevant associated data.
|
with all relevant associated data.
|
||||||
* :meth:`~AbstractDCS._mpp_cluster_loader`:
|
* :meth:`~AbstractDCS._citus_cluster_loader`:
|
||||||
Similar to above but specifically representing MPP group and workers information.
|
Similar to above but specifically representing Citus group and workers information.
|
||||||
* :meth:`~AbstractDCS._load_cluster`:
|
* :meth:`~AbstractDCS._load_cluster`:
|
||||||
main method for calling specific ``loader`` method to build the :class:`Cluster` object representing the
|
main method for calling specific ``loader`` method to build the :class:`Cluster` object representing the
|
||||||
state and topology of the cluster.
|
state and topology of the cluster.
|
||||||
@@ -1358,15 +1424,15 @@ class AbstractDCS(abc.ABC):
|
|||||||
_SYNC = 'sync'
|
_SYNC = 'sync'
|
||||||
_FAILSAFE = 'failsafe'
|
_FAILSAFE = 'failsafe'
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: 'AbstractMPP') -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
"""Prepare DCS paths, MPP object, initial values for state information and processing dependencies.
|
"""Prepare DCS paths, Citus group ID, initial values for state information and processing dependencies.
|
||||||
|
|
||||||
:ivar config: :class:`dict`, reference to config section of selected DCS.
|
:ivar config: :class:`dict`, reference to config section of selected DCS.
|
||||||
i.e.: ``zookeeper`` for zookeeper, ``etcd`` for etcd, etc...
|
i.e.: ``zookeeper`` for zookeeper, ``etcd`` for etcd, etc...
|
||||||
"""
|
"""
|
||||||
self._mpp = mpp
|
|
||||||
self._name = config['name']
|
self._name = config['name']
|
||||||
self._base_path = re.sub('/+', '/', '/'.join(['', config.get('namespace', 'service'), config['scope']]))
|
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._set_loop_wait(config.get('loop_wait', 10))
|
||||||
|
|
||||||
self._ctl = bool(config.get('patronictl', False))
|
self._ctl = bool(config.get('patronictl', False))
|
||||||
@@ -1379,11 +1445,6 @@ class AbstractDCS(abc.ABC):
|
|||||||
self._last_failsafe: Optional[Dict[str, str]] = {}
|
self._last_failsafe: Optional[Dict[str, str]] = {}
|
||||||
self.event = Event()
|
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:
|
def client_path(self, path: str) -> str:
|
||||||
"""Construct the absolute key name from appropriate parts for the DCS type.
|
"""Construct the absolute key name from appropriate parts for the DCS type.
|
||||||
|
|
||||||
@@ -1392,8 +1453,8 @@ class AbstractDCS(abc.ABC):
|
|||||||
:returns: absolute key name for the current Patroni cluster.
|
:returns: absolute key name for the current Patroni cluster.
|
||||||
"""
|
"""
|
||||||
components = [self._base_path]
|
components = [self._base_path]
|
||||||
if self._mpp.is_enabled():
|
if self._citus_group:
|
||||||
components.append(str(self._mpp.group))
|
components.append(self._citus_group)
|
||||||
components.append(path.lstrip('/'))
|
components.append(path.lstrip('/'))
|
||||||
return '/'.join(components)
|
return '/'.join(components)
|
||||||
|
|
||||||
@@ -1494,21 +1555,22 @@ class AbstractDCS(abc.ABC):
|
|||||||
return self._last_seen
|
return self._last_seen
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def _postgresql_cluster_loader(self, path: Any) -> Cluster:
|
def _cluster_loader(self, path: Any) -> Cluster:
|
||||||
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
|
"""Load and build the :class:`Cluster` object from DCS, which represents a single Patroni or Citus cluster.
|
||||||
|
|
||||||
:param path: the path in DCS where to load :class:`Cluster` from.
|
:param path: the path in DCS where to load Cluster(s) from.
|
||||||
|
|
||||||
:returns: :class:`Cluster` instance.
|
:returns: :class:`Cluster` instance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def _mpp_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
|
def _citus_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
|
||||||
"""Load and build all PostgreSQL clusters from a single MPP cluster.
|
"""Load and build all Patroni clusters from a single Citus cluster.
|
||||||
|
|
||||||
:param path: the path in DCS where to load Cluster(s) from.
|
: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.
|
: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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
@@ -1523,14 +1585,13 @@ class AbstractDCS(abc.ABC):
|
|||||||
the :meth:`~AbstractDCS.get_cluster` method.
|
the :meth:`~AbstractDCS.get_cluster` method.
|
||||||
|
|
||||||
:param path: the path in DCS where to load Cluster(s) from.
|
:param path: the path in DCS where to load Cluster(s) from.
|
||||||
:param loader: one of :meth:`~AbstractDCS._postgresql_cluster_loader` or
|
:param loader: one of :meth:`~AbstractDCS._cluster_loader` or :meth:`~AbstractDCS._citus_cluster_loader`.
|
||||||
:meth:`~AbstractDCS._mpp_cluster_loader`.
|
|
||||||
|
|
||||||
:raise: :exc:`~DCSError` in case of communication problems with DCS. If the current node was running as a
|
: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.
|
primary and exception raised, instance would be demoted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __get_postgresql_cluster(self, path: Optional[str] = None) -> Cluster:
|
def __get_patroni_cluster(self, path: Optional[str] = None) -> Cluster:
|
||||||
"""Low level method to load a :class:`Cluster` object from DCS.
|
"""Low level method to load a :class:`Cluster` object from DCS.
|
||||||
|
|
||||||
:param path: optional client path in DCS backend to load from.
|
:param path: optional client path in DCS backend to load from.
|
||||||
@@ -1539,43 +1600,42 @@ class AbstractDCS(abc.ABC):
|
|||||||
"""
|
"""
|
||||||
if path is None:
|
if path is None:
|
||||||
path = self.client_path('')
|
path = self.client_path('')
|
||||||
cluster = self._load_cluster(path, self._postgresql_cluster_loader)
|
cluster = self._load_cluster(path, self._cluster_loader)
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
assert isinstance(cluster, Cluster)
|
assert isinstance(cluster, Cluster)
|
||||||
return cluster
|
return cluster
|
||||||
|
|
||||||
def is_mpp_coordinator(self) -> bool:
|
def is_citus_coordinator(self) -> bool:
|
||||||
""":class:`Cluster` instance has a Coordinator group ID.
|
""":class:`Cluster` instance has a Citus Coordinator group ID.
|
||||||
|
|
||||||
:returns: ``True`` if the given node is running as the MPP Coordinator.
|
:returns: ``True`` if the given node is running as Citus Coordinator (``group=0``).
|
||||||
"""
|
"""
|
||||||
return self._mpp.is_coordinator()
|
return self._citus_group == str(CITUS_COORDINATOR_GROUP_ID)
|
||||||
|
|
||||||
def get_mpp_coordinator(self) -> Optional[Cluster]:
|
def get_citus_coordinator(self) -> Optional[Cluster]:
|
||||||
"""Load the PostgreSQL cluster for the MPP Coordinator.
|
"""Load the Patroni cluster for the Citus Coordinator.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
This method is only executed on the worker nodes to find the coordinator.
|
This method is only executed on the worker nodes (``group!=0``) to find the coordinator.
|
||||||
|
|
||||||
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
|
:returns: Select :class:`Cluster` instance associated with the Citus Coordinator group ID.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return self.__get_postgresql_cluster(f'{self._base_path}/{self._mpp.coordinator_group_id}/')
|
return self.__get_patroni_cluster(f'{self._base_path}/{CITUS_COORDINATOR_GROUP_ID}/')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Failed to load %s coordinator cluster from %s: %r',
|
logger.error('Failed to load Citus coordinator cluster from %s: %r', self.__class__.__name__, e)
|
||||||
self._mpp.type, self.__class__.__name__, e)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_mpp_cluster(self) -> Cluster:
|
def _get_citus_cluster(self) -> Cluster:
|
||||||
"""Load MPP cluster from DCS.
|
"""Load Citus cluster from DCS.
|
||||||
|
|
||||||
:returns: A MPP :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
|
:returns: A Citus :class:`Cluster` instance for the coordinator with workers clusters in the `Cluster.workers`
|
||||||
dict.
|
dict.
|
||||||
"""
|
"""
|
||||||
groups = self._load_cluster(self._base_path + '/', self._mpp_cluster_loader)
|
groups = self._load_cluster(self._base_path + '/', self._citus_cluster_loader)
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
assert isinstance(groups, dict)
|
assert isinstance(groups, dict)
|
||||||
cluster = groups.pop(self._mpp.coordinator_group_id, Cluster.empty())
|
cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
|
||||||
cluster.workers.update(groups)
|
cluster.workers.update(groups)
|
||||||
return cluster
|
return cluster
|
||||||
|
|
||||||
@@ -1586,12 +1646,12 @@ class AbstractDCS(abc.ABC):
|
|||||||
Stores copy of time, status and failsafe values for comparison in DCS update decisions.
|
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.
|
Caching is required to avoid overhead placed upon the REST API.
|
||||||
|
|
||||||
Returns either a PostgreSQL or MPP implementation of :class:`Cluster` depending on availability.
|
Returns either a Citus or Patroni implementation of :class:`Cluster` depending on availability.
|
||||||
|
|
||||||
:returns:
|
:returns:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cluster = self._get_mpp_cluster() if self.is_mpp_coordinator() else self.__get_postgresql_cluster()
|
cluster = self._get_citus_cluster() if self.is_citus_coordinator() else self.__get_patroni_cluster()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.reset_cluster()
|
self.reset_cluster()
|
||||||
raise
|
raise
|
||||||
@@ -1871,23 +1931,18 @@ class AbstractDCS(abc.ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sync_state(leader: Optional[str], sync_standby: Optional[Collection[str]],
|
def sync_state(leader: Optional[str], sync_standby: Optional[Collection[str]]) -> Dict[str, Any]:
|
||||||
quorum: Optional[int]) -> Dict[str, Any]:
|
|
||||||
"""Build ``sync_state`` dictionary.
|
"""Build ``sync_state`` dictionary.
|
||||||
|
|
||||||
:param leader: name of the leader node that manages ``/sync`` key.
|
:param leader: name of the leader node that manages ``/sync`` key.
|
||||||
:param sync_standby: collection of currently known synchronous standby node names.
|
:param sync_standby: collection of currently known synchronous standby node names.
|
||||||
:param quorum: if the node from :attr:`~SyncState.sync_standby` list is doing a leader race it should
|
|
||||||
see at least :attr:`~SyncState.quorum` other nodes from the
|
|
||||||
:attr:`~SyncState.sync_standby` + :attr:`~SyncState.leader` list
|
|
||||||
|
|
||||||
:returns: dictionary that later could be serialized to JSON or saved directly to DCS.
|
:returns: dictionary that later could be serialized to JSON or saved directly to DCS.
|
||||||
"""
|
"""
|
||||||
return {'leader': leader, 'quorum': quorum,
|
return {'leader': leader, 'sync_standby': ','.join(sorted(sync_standby)) if sync_standby else None}
|
||||||
'sync_standby': ','.join(sorted(sync_standby)) if sync_standby else None}
|
|
||||||
|
|
||||||
def write_sync_state(self, leader: Optional[str], sync_standby: Optional[Collection[str]],
|
def write_sync_state(self, leader: Optional[str], sync_standby: Optional[Collection[str]],
|
||||||
quorum: Optional[int], version: Optional[Any] = None) -> Optional[SyncState]:
|
version: Optional[Any] = None) -> Optional[SyncState]:
|
||||||
"""Write the new synchronous state to DCS.
|
"""Write the new synchronous state to DCS.
|
||||||
|
|
||||||
Calls :meth:`~AbstractDCS.sync_state` to build a dictionary and then calls DCS specific
|
Calls :meth:`~AbstractDCS.sync_state` to build a dictionary and then calls DCS specific
|
||||||
@@ -1896,13 +1951,10 @@ class AbstractDCS(abc.ABC):
|
|||||||
:param leader: name of the leader node that manages ``/sync`` key.
|
:param leader: name of the leader node that manages ``/sync`` key.
|
||||||
:param sync_standby: collection of currently known synchronous standby node names.
|
:param sync_standby: collection of currently known synchronous standby node names.
|
||||||
:param version: for conditional update of the key/object.
|
:param version: for conditional update of the key/object.
|
||||||
:param quorum: if the node from :attr:`~SyncState.sync_standby` list is doing a leader race it should
|
|
||||||
see at least :attr:`~SyncState.quorum` other nodes from the
|
|
||||||
:attr:`~SyncState.sync_standby` + :attr:`~SyncState.leader` list
|
|
||||||
|
|
||||||
:returns: the new :class:`SyncState` object or ``None``.
|
:returns: the new :class:`SyncState` object or ``None``.
|
||||||
"""
|
"""
|
||||||
sync_value = self.sync_state(leader, sync_standby, quorum)
|
sync_value = self.sync_state(leader, sync_standby)
|
||||||
ret = self.set_sync_state_value(json.dumps(sync_value, separators=(',', ':')), version)
|
ret = self.set_sync_state_value(json.dumps(sync_value, separators=(',', ':')), version)
|
||||||
if not isinstance(ret, bool):
|
if not isinstance(ret, bool):
|
||||||
return SyncState.from_node(ret, sync_value)
|
return SyncState.from_node(ret, sync_value)
|
||||||
|
|||||||
+9
-25
@@ -16,9 +16,8 @@ from urllib.parse import urlencode, urlparse, quote
|
|||||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||||
TimelineHistory, ReturnFalseException, catch_return_false_exception
|
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||||
from ..exceptions import DCSError
|
from ..exceptions import DCSError
|
||||||
from ..postgresql.mpp import AbstractMPP
|
|
||||||
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
@@ -233,8 +232,8 @@ def service_name_from_scope_name(scope_name: str) -> str:
|
|||||||
|
|
||||||
class Consul(AbstractDCS):
|
class Consul(AbstractDCS):
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
super(Consul, self).__init__(config, mpp)
|
super(Consul, self).__init__(config)
|
||||||
self._base_path = self._base_path[1:]
|
self._base_path = self._base_path[1:]
|
||||||
self._scope = config['scope']
|
self._scope = config['scope']
|
||||||
self._session = None
|
self._session = None
|
||||||
@@ -420,13 +419,7 @@ class Consul(AbstractDCS):
|
|||||||
def _consistency(self) -> str:
|
def _consistency(self) -> str:
|
||||||
return 'consistent' if self._ctl else self._client.consistency
|
return 'consistent' if self._ctl else self._client.consistency
|
||||||
|
|
||||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
def _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)
|
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||||
if results is None:
|
if results is None:
|
||||||
return Cluster.empty()
|
return Cluster.empty()
|
||||||
@@ -437,18 +430,12 @@ class Consul(AbstractDCS):
|
|||||||
|
|
||||||
return self._cluster_from_nodes(nodes)
|
return self._cluster_from_nodes(nodes)
|
||||||
|
|
||||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
def _citus_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)
|
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
|
||||||
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
|
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
|
||||||
for node in results or []:
|
for node in results or []:
|
||||||
key = node['Key'][len(path):].split('/', 1)
|
key = node['Key'][len(path):].split('/', 1)
|
||||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||||
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
node['Value'] = (node['Value'] or b'').decode('utf-8')
|
||||||
clusters[int(key[0])][key[1]] = node
|
clusters[int(key[0])][key[1]] = node
|
||||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||||
@@ -577,17 +564,14 @@ class Consul(AbstractDCS):
|
|||||||
try:
|
try:
|
||||||
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||||
except InvalidSession:
|
except InvalidSession:
|
||||||
self._session = None
|
|
||||||
|
|
||||||
if not retry.ensure_deadline(0):
|
|
||||||
logger.error('Our session disappeared from Consul. Deadline exceeded, giving up')
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
|
logger.error('Our session disappeared from Consul. Will try to get a new one and retry attempt')
|
||||||
|
self._session = None
|
||||||
|
retry.ensure_deadline(0)
|
||||||
|
|
||||||
retry(self._do_refresh_session)
|
retry(self._do_refresh_session)
|
||||||
|
|
||||||
retry.ensure_deadline(1, ConsulError('_do_attempt_to_acquire_leader timeout'))
|
retry.ensure_deadline(1, ConsulError('_do_attempt_to_acquire_leader timeout'))
|
||||||
|
|
||||||
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
|
||||||
|
|
||||||
@catch_return_false_exception
|
@catch_return_false_exception
|
||||||
|
|||||||
+8
-21
@@ -22,9 +22,8 @@ from urllib3 import Timeout
|
|||||||
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
|
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
|
||||||
|
|
||||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||||
TimelineHistory, ReturnFalseException, catch_return_false_exception
|
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||||
from ..exceptions import DCSError
|
from ..exceptions import DCSError
|
||||||
from ..postgresql.mpp import AbstractMPP
|
|
||||||
from ..request import get as requests_get
|
from ..request import get as requests_get
|
||||||
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
@@ -471,9 +470,9 @@ class EtcdClient(AbstractEtcdClientWithFailover):
|
|||||||
|
|
||||||
class AbstractEtcd(AbstractDCS):
|
class AbstractEtcd(AbstractDCS):
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP, client_cls: Type[AbstractEtcdClientWithFailover],
|
def __init__(self, config: Dict[str, Any], client_cls: Type[AbstractEtcdClientWithFailover],
|
||||||
retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
|
retry_errors_cls: Union[Type[Exception], Tuple[Type[Exception], ...]]) -> None:
|
||||||
super(AbstractEtcd, self).__init__(config, mpp)
|
super(AbstractEtcd, self).__init__(config)
|
||||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||||
retry_exceptions=retry_errors_cls)
|
retry_exceptions=retry_errors_cls)
|
||||||
self._ttl = int(config.get('ttl') or 30)
|
self._ttl = int(config.get('ttl') or 30)
|
||||||
@@ -646,8 +645,8 @@ def catch_etcd_errors(func: Callable[..., Any]) -> Any:
|
|||||||
|
|
||||||
class Etcd(AbstractEtcd):
|
class Etcd(AbstractEtcd):
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
super(Etcd, self).__init__(config, mpp, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
|
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
|
||||||
self.__do_not_watch = False
|
self.__do_not_watch = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -710,13 +709,7 @@ class Etcd(AbstractEtcd):
|
|||||||
|
|
||||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||||
|
|
||||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
def _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:
|
try:
|
||||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||||
except etcd.EtcdKeyNotFound:
|
except etcd.EtcdKeyNotFound:
|
||||||
@@ -724,13 +717,7 @@ class Etcd(AbstractEtcd):
|
|||||||
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
|
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
|
||||||
return self._cluster_from_nodes(result.etcd_index, nodes)
|
return self._cluster_from_nodes(result.etcd_index, nodes)
|
||||||
|
|
||||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
def _citus_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:
|
try:
|
||||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||||
except etcd.EtcdKeyNotFound:
|
except etcd.EtcdKeyNotFound:
|
||||||
@@ -739,7 +726,7 @@ class Etcd(AbstractEtcd):
|
|||||||
clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
|
clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
|
||||||
for node in result.leaves:
|
for node in result.leaves:
|
||||||
key = node.key[len(result.key):].lstrip('/').split('/', 1)
|
key = node.key[len(result.key):].lstrip('/').split('/', 1)
|
||||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||||
clusters[int(key[0])][key[1]] = node
|
clusters[int(key[0])][key[1]] = node
|
||||||
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
|
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
|
||||||
|
|
||||||
|
|||||||
+35
-42
@@ -16,10 +16,9 @@ from threading import Condition, Lock, Thread
|
|||||||
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
||||||
|
|
||||||
from . import ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
from . import ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||||
TimelineHistory, catch_return_false_exception
|
TimelineHistory, catch_return_false_exception, citus_group_re
|
||||||
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
|
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
|
||||||
from ..exceptions import DCSError, PatroniException
|
from ..exceptions import DCSError, PatroniException
|
||||||
from ..postgresql.mpp import AbstractMPP
|
|
||||||
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
|
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -198,6 +197,12 @@ def build_range_request(key: str, range_end: Union[bytes, str, None] = None) ->
|
|||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class ReAuthenticateMode(IntEnum):
|
||||||
|
NOT_REQUIRED = 0
|
||||||
|
REQUIRED = 1
|
||||||
|
WITHOUT_WATCHER_RESTART = 2
|
||||||
|
|
||||||
|
|
||||||
def _handle_auth_errors(func: Callable[..., Any]) -> Any:
|
def _handle_auth_errors(func: Callable[..., Any]) -> Any:
|
||||||
def wrapper(self: 'Etcd3Client', *args: Any, **kwargs: Any) -> Any:
|
def wrapper(self: 'Etcd3Client', *args: Any, **kwargs: Any) -> Any:
|
||||||
return self.handle_auth_errors(func, *args, **kwargs)
|
return self.handle_auth_errors(func, *args, **kwargs)
|
||||||
@@ -209,7 +214,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
|||||||
ERROR_CLS = Etcd3Error
|
ERROR_CLS = Etcd3Error
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
|
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
|
||||||
self._reauthenticate = False
|
self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||||
self._token = None
|
self._token = None
|
||||||
self._cluster_version: Tuple[int, ...] = tuple()
|
self._cluster_version: Tuple[int, ...] = tuple()
|
||||||
super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl)
|
super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl)
|
||||||
@@ -288,7 +293,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
|||||||
fields['retry'] = retry
|
fields['retry'] = retry
|
||||||
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
|
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
|
||||||
|
|
||||||
def authenticate(self, *, retry: Optional[Retry] = None) -> bool:
|
def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool:
|
||||||
if self._use_proxies and not self._cluster_version:
|
if self._use_proxies and not self._cluster_version:
|
||||||
kwargs = self._prepare_common_parameters(1)
|
kwargs = self._prepare_common_parameters(1)
|
||||||
self._ensure_version_prefix(self._base_uri, **kwargs)
|
self._ensure_version_prefix(self._base_uri, **kwargs)
|
||||||
@@ -310,18 +315,20 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
|||||||
|
|
||||||
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any,
|
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any,
|
||||||
retry: Optional[Retry] = None, **kwargs: Any) -> Any:
|
retry: Optional[Retry] = None, **kwargs: Any) -> Any:
|
||||||
reauthenticated = False
|
|
||||||
exc = None
|
exc = None
|
||||||
while True:
|
while True:
|
||||||
if self._reauthenticate:
|
if self._reauthenticate_reason:
|
||||||
if self.username and self.password:
|
if self.username and self.password:
|
||||||
self.authenticate(retry=retry)
|
self.authenticate(
|
||||||
self._reauthenticate = False
|
restart_watcher=self._reauthenticate_reason != ReAuthenticateMode.WITHOUT_WATCHER_RESTART,
|
||||||
|
retry=retry)
|
||||||
|
self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||||
|
if retry:
|
||||||
|
retry.ensure_deadline(0)
|
||||||
else:
|
else:
|
||||||
msg = 'Username or password not set, authentication is not possible'
|
msg = 'Username or password not set, authentication is not possible'
|
||||||
logger.fatal(msg)
|
logger.fatal(msg)
|
||||||
raise exc or Etcd3Exception(msg)
|
raise exc or Etcd3Exception(msg)
|
||||||
reauthenticated = True
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return func(self, *args, retry=retry, **kwargs)
|
return func(self, *args, retry=retry, **kwargs)
|
||||||
@@ -339,12 +346,11 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
|||||||
except AuthOldRevision as e:
|
except AuthOldRevision as e:
|
||||||
logger.error('Auth token is for old revision of auth store')
|
logger.error('Auth token is for old revision of auth store')
|
||||||
exc = e
|
exc = e
|
||||||
self._reauthenticate = True
|
self._reauthenticate_reason = ReAuthenticateMode.WITHOUT_WATCHER_RESTART \
|
||||||
if retry:
|
if isinstance(exc, AuthOldRevision) else ReAuthenticateMode.REQUIRED
|
||||||
logger.error('retry = %s', retry)
|
if not retry:
|
||||||
retry.ensure_deadline(0.5, exc)
|
|
||||||
elif reauthenticated:
|
|
||||||
raise exc
|
raise exc
|
||||||
|
retry.ensure_deadline(0.5, exc)
|
||||||
|
|
||||||
@_handle_auth_errors
|
@_handle_auth_errors
|
||||||
def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
|
def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
|
||||||
@@ -596,6 +602,12 @@ class PatroniEtcd3Client(Etcd3Client):
|
|||||||
super(PatroniEtcd3Client, self).set_base_uri(value)
|
super(PatroniEtcd3Client, self).set_base_uri(value)
|
||||||
self._restart_watcher()
|
self._restart_watcher()
|
||||||
|
|
||||||
|
def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool:
|
||||||
|
ret = super(PatroniEtcd3Client, self).authenticate(restart_watcher=restart_watcher, retry=retry)
|
||||||
|
if ret and restart_watcher:
|
||||||
|
self._restart_watcher()
|
||||||
|
return ret
|
||||||
|
|
||||||
def _wait_cache(self, timeout: float) -> None:
|
def _wait_cache(self, timeout: float) -> None:
|
||||||
stop_time = time.time() + timeout
|
stop_time = time.time() + timeout
|
||||||
while self._kv_cache and not self._kv_cache.is_ready():
|
while self._kv_cache and not self._kv_cache.is_ready():
|
||||||
@@ -659,9 +671,8 @@ class PatroniEtcd3Client(Etcd3Client):
|
|||||||
|
|
||||||
class Etcd3(AbstractEtcd):
|
class Etcd3(AbstractEtcd):
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
super(Etcd3, self).__init__(config, mpp, PatroniEtcd3Client,
|
super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition))
|
||||||
(DeadlineExceeded, Unavailable, FailedPrecondition))
|
|
||||||
self.__do_not_watch = False
|
self.__do_not_watch = False
|
||||||
self._lease = None
|
self._lease = None
|
||||||
self._last_lease_refresh = 0
|
self._last_lease_refresh = 0
|
||||||
@@ -720,11 +731,7 @@ class Etcd3(AbstractEtcd):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def cluster_prefix(self) -> str:
|
def cluster_prefix(self) -> str:
|
||||||
"""Construct the cluster prefix for the cluster.
|
return self._base_path + '/' if self.is_citus_coordinator() else self.client_path('')
|
||||||
|
|
||||||
: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
|
@staticmethod
|
||||||
def member(node: Dict[str, str]) -> Member:
|
def member(node: Dict[str, str]) -> Member:
|
||||||
@@ -778,30 +785,18 @@ class Etcd3(AbstractEtcd):
|
|||||||
|
|
||||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||||
|
|
||||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
def _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
|
nodes = {node['key'][len(path):]: node
|
||||||
for node in self._client.get_cluster(path)
|
for node in self._client.get_cluster(path)
|
||||||
if node['key'].startswith(path)}
|
if node['key'].startswith(path)}
|
||||||
return self._cluster_from_nodes(nodes)
|
return self._cluster_from_nodes(nodes)
|
||||||
|
|
||||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
def _citus_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)
|
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
|
||||||
path = self._base_path + '/'
|
path = self._base_path + '/'
|
||||||
for node in self._client.get_cluster(path):
|
for node in self._client.get_cluster(path):
|
||||||
key = node['key'][len(path):].split('/', 1)
|
key = node['key'][len(path):].split('/', 1)
|
||||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||||
clusters[int(key[0])][key[1]] = node
|
clusters[int(key[0])][key[1]] = node
|
||||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
||||||
|
|
||||||
@@ -853,16 +848,14 @@ class Etcd3(AbstractEtcd):
|
|||||||
try:
|
try:
|
||||||
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
|
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
|
||||||
except LeaseNotFound:
|
except LeaseNotFound:
|
||||||
self._lease = None
|
|
||||||
if not retry.ensure_deadline(0):
|
|
||||||
logger.error('Our lease disappeared from Etcd. Deadline exceeded, giving up')
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
|
logger.error('Our lease disappeared from Etcd. Will try to get a new one and retry attempt')
|
||||||
|
self._lease = None
|
||||||
|
retry.ensure_deadline(0)
|
||||||
|
|
||||||
_retry(self._do_refresh_lease)
|
_retry(self._do_refresh_lease)
|
||||||
|
|
||||||
retry.ensure_deadline(1, Etcd3Error('_do_attempt_to_acquire_leader timeout'))
|
retry.ensure_deadline(1, Etcd3Error('_do_attempt_to_acquire_leader timeout'))
|
||||||
|
|
||||||
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
|
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
|
||||||
|
|
||||||
@catch_return_false_exception
|
@catch_return_false_exception
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from typing import Any, Callable, Dict, List, Union
|
|||||||
|
|
||||||
from . import Cluster
|
from . import Cluster
|
||||||
from .zookeeper import ZooKeeper
|
from .zookeeper import ZooKeeper
|
||||||
from ..postgresql.mpp import AbstractMPP
|
|
||||||
from ..request import get as requests_get
|
from ..request import get as requests_get
|
||||||
from ..utils import uri
|
from ..utils import uri
|
||||||
|
|
||||||
@@ -67,10 +66,10 @@ class ExhibitorEnsembleProvider(object):
|
|||||||
|
|
||||||
class Exhibitor(ZooKeeper):
|
class Exhibitor(ZooKeeper):
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
interval = config.get('poll_interval', 300)
|
interval = config.get('poll_interval', 300)
|
||||||
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
|
self._ensemble_provider = ExhibitorEnsembleProvider(config['hosts'], config['port'], poll_interval=interval)
|
||||||
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts}, mpp)
|
super(Exhibitor, self).__init__({**config, 'hosts': self._ensemble_provider.zookeeper_hosts})
|
||||||
|
|
||||||
def _load_cluster(
|
def _load_cluster(
|
||||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||||
|
|||||||
+23
-43
@@ -19,9 +19,9 @@ from urllib3.exceptions import HTTPError
|
|||||||
from threading import Condition, Lock, Thread
|
from threading import Condition, Lock, Thread
|
||||||
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
|
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
|
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||||
|
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
|
||||||
from ..exceptions import DCSError
|
from ..exceptions import DCSError
|
||||||
from ..postgresql.mpp import AbstractMPP
|
|
||||||
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
|
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
|
||||||
Retry, RetryFailedError, tzutc, uri, USER_AGENT
|
Retry, RetryFailedError, tzutc, uri, USER_AGENT
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
@@ -746,7 +746,9 @@ class ObjectCache(Thread):
|
|||||||
|
|
||||||
class Kubernetes(AbstractDCS):
|
class Kubernetes(AbstractDCS):
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
_CITUS_LABEL = 'citus-group'
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
self._labels = deepcopy(config['labels'])
|
self._labels = deepcopy(config['labels'])
|
||||||
self._labels[config.get('scope_label', 'cluster-name')] = config['scope']
|
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())
|
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
|
||||||
@@ -757,9 +759,9 @@ class Kubernetes(AbstractDCS):
|
|||||||
self._standby_leader_label_value = config.get('standby_leader_label_value', 'master')
|
self._standby_leader_label_value = config.get('standby_leader_label_value', 'master')
|
||||||
self._tmp_role_label = config.get('tmp_role_label')
|
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
|
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
|
||||||
super(Kubernetes, self).__init__({**config, 'namespace': ''}, mpp)
|
super(Kubernetes, self).__init__({**config, 'namespace': ''})
|
||||||
if self._mpp.is_enabled():
|
if self._citus_group:
|
||||||
self._labels[self._mpp.k8s_group_label] = str(self._mpp.group)
|
self._labels[self._CITUS_LABEL] = self._citus_group
|
||||||
|
|
||||||
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
|
||||||
retry_exceptions=KubernetesRetriableException)
|
retry_exceptions=KubernetesRetriableException)
|
||||||
@@ -934,32 +936,20 @@ class Kubernetes(AbstractDCS):
|
|||||||
|
|
||||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||||
|
|
||||||
def _postgresql_cluster_loader(self, path: Dict[str, Any]) -> Cluster:
|
def _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())
|
return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values())
|
||||||
|
|
||||||
def _mpp_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
|
def _citus_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))
|
clusters: Dict[str, Dict[str, Dict[str, K8sObject]]] = defaultdict(lambda: defaultdict(dict))
|
||||||
|
|
||||||
for name, pod in path['pods'].items():
|
for name, pod in path['pods'].items():
|
||||||
group = pod.metadata.labels.get(self._mpp.k8s_group_label)
|
group = pod.metadata.labels.get(self._CITUS_LABEL)
|
||||||
if group and self._mpp.group_re.match(group):
|
if group and citus_group_re.match(group):
|
||||||
clusters[group]['pods'][name] = pod
|
clusters[group]['pods'][name] = pod
|
||||||
|
|
||||||
for name, kind in path['nodes'].items():
|
for name, kind in path['nodes'].items():
|
||||||
group = kind.metadata.labels.get(self._mpp.k8s_group_label)
|
group = kind.metadata.labels.get(self._CITUS_LABEL)
|
||||||
if group and self._mpp.group_re.match(group):
|
if group and citus_group_re.match(group):
|
||||||
clusters[group]['nodes'][name] = kind
|
clusters[group]['nodes'][name] = kind
|
||||||
return {int(group): self._cluster_from_nodes(group, value['nodes'], value['pods'].values())
|
return {int(group): self._cluster_from_nodes(group, value['nodes'], value['pods'].values())
|
||||||
for group, value in clusters.items()}
|
for group, value in clusters.items()}
|
||||||
@@ -975,9 +965,9 @@ class Kubernetes(AbstractDCS):
|
|||||||
with self._condition:
|
with self._condition:
|
||||||
self._wait_caches(stop_time)
|
self._wait_caches(stop_time)
|
||||||
pods = {name: pod for name, pod in self._pods.copy().items()
|
pods = {name: pod for name, pod in self._pods.copy().items()
|
||||||
if not group or pod.metadata.labels.get(self._mpp.k8s_group_label) == group}
|
if not group or pod.metadata.labels.get(self._CITUS_LABEL) == group}
|
||||||
nodes = {name: kind for name, kind in self._kinds.copy().items()
|
nodes = {name: kind for name, kind in self._kinds.copy().items()
|
||||||
if not group or kind.metadata.labels.get(self._mpp.k8s_group_label) == group}
|
if not group or kind.metadata.labels.get(self._CITUS_LABEL) == group}
|
||||||
return loader({'group': group, 'pods': pods, 'nodes': nodes})
|
return loader({'group': group, 'pods': pods, 'nodes': nodes})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('get_cluster')
|
logger.exception('get_cluster')
|
||||||
@@ -986,24 +976,17 @@ class Kubernetes(AbstractDCS):
|
|||||||
def _load_cluster(
|
def _load_cluster(
|
||||||
self, path: str, loader: Callable[[Any], Union[Cluster, Dict[int, Cluster]]]
|
self, path: str, loader: Callable[[Any], Union[Cluster, Dict[int, Cluster]]]
|
||||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||||
group = str(self._mpp.group) if self._mpp.is_enabled() and path == self.client_path('') else None
|
group = self._citus_group if path == self.client_path('') else None
|
||||||
return self.__load_cluster(group, loader)
|
return self.__load_cluster(group, loader)
|
||||||
|
|
||||||
def get_mpp_coordinator(self) -> Optional[Cluster]:
|
def get_citus_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:
|
try:
|
||||||
ret = self.__load_cluster(str(self._mpp.coordinator_group_id), self._postgresql_cluster_loader)
|
ret = self.__load_cluster(str(CITUS_COORDINATOR_GROUP_ID), self._cluster_loader)
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
assert isinstance(ret, Cluster)
|
assert isinstance(ret, Cluster)
|
||||||
return ret
|
return ret
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Failed to load %s coordinator cluster from Kubernetes: %r', self._mpp.type, e)
|
logger.error('Failed to load Citus coordinator cluster from Kubernetes: %r', e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compare_ports(p1: K8sObject, p2: K8sObject) -> bool:
|
def compare_ports(p1: K8sObject, p2: K8sObject) -> bool:
|
||||||
@@ -1370,18 +1353,15 @@ class Kubernetes(AbstractDCS):
|
|||||||
raise NotImplementedError # pragma: no cover
|
raise NotImplementedError # pragma: no cover
|
||||||
|
|
||||||
def write_sync_state(self, leader: Optional[str], sync_standby: Optional[Collection[str]],
|
def write_sync_state(self, leader: Optional[str], sync_standby: Optional[Collection[str]],
|
||||||
quorum: Optional[int], version: Optional[str] = None) -> Optional[SyncState]:
|
version: Optional[str] = None) -> Optional[SyncState]:
|
||||||
"""Prepare and write annotations to $SCOPE-sync Endpoint or ConfigMap.
|
"""Prepare and write annotations to $SCOPE-sync Endpoint or ConfigMap.
|
||||||
|
|
||||||
:param leader: name of the leader node that manages /sync key
|
:param leader: name of the leader node that manages /sync key
|
||||||
:param sync_standby: collection of currently known synchronous standby node names
|
:param sync_standby: collection of currently known synchronous standby node names
|
||||||
:param quorum: if the node from sync_standby list is doing a leader race it should
|
|
||||||
see at least quorum other nodes from the sync_standby + leader list
|
|
||||||
:param version: last known `resource_version` for conditional update of the object
|
:param version: last known `resource_version` for conditional update of the object
|
||||||
:returns: the new :class:`SyncState` object or None
|
:returns: the new :class:`SyncState` object or None
|
||||||
"""
|
"""
|
||||||
sync_state = self.sync_state(leader, sync_standby, quorum)
|
sync_state = self.sync_state(leader, sync_standby)
|
||||||
sync_state['quorum'] = str(sync_state['quorum']) if sync_state['quorum'] is not None else None
|
|
||||||
ret = self.patch_or_create(self.sync_path, sync_state, version, False)
|
ret = self.patch_or_create(self.sync_path, sync_state, version, False)
|
||||||
if not isinstance(ret, bool):
|
if not isinstance(ret, bool):
|
||||||
return SyncState.from_node(ret.metadata.resource_version, sync_state)
|
return SyncState.from_node(ret.metadata.resource_version, sync_state)
|
||||||
@@ -1393,7 +1373,7 @@ class Kubernetes(AbstractDCS):
|
|||||||
:param version: last known `resource_version` for conditional update of the object
|
:param version: last known `resource_version` for conditional update of the object
|
||||||
:returns: `True` if "delete" was successful
|
:returns: `True` if "delete" was successful
|
||||||
"""
|
"""
|
||||||
return self.write_sync_state(None, None, None, version=version) is not None
|
return self.write_sync_state(None, None, version=version) is not None
|
||||||
|
|
||||||
def watch(self, leader_version: Optional[str], timeout: float) -> bool:
|
def watch(self, leader_version: Optional[str], timeout: float) -> bool:
|
||||||
if self.__do_not_watch:
|
if self.__do_not_watch:
|
||||||
|
|||||||
+7
-19
@@ -12,9 +12,9 @@ from pysyncobj.transport import TCPTransport, CONNECTION_STATE
|
|||||||
from pysyncobj.utility import TcpUtility
|
from pysyncobj.utility import TcpUtility
|
||||||
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
|
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
|
||||||
|
|
||||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
|
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||||
|
TimelineHistory, citus_group_re
|
||||||
from ..exceptions import DCSError
|
from ..exceptions import DCSError
|
||||||
from ..postgresql.mpp import AbstractMPP
|
|
||||||
from ..utils import validate_directory
|
from ..utils import validate_directory
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
@@ -285,8 +285,8 @@ class KVStoreTTL(DynMemberSyncObj):
|
|||||||
|
|
||||||
class Raft(AbstractDCS):
|
class Raft(AbstractDCS):
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
super(Raft, self).__init__(config, mpp)
|
super(Raft, self).__init__(config)
|
||||||
self._ttl = int(config.get('ttl') or 30)
|
self._ttl = int(config.get('ttl') or 30)
|
||||||
|
|
||||||
ready_event = threading.Event()
|
ready_event = threading.Event()
|
||||||
@@ -375,31 +375,19 @@ class Raft(AbstractDCS):
|
|||||||
|
|
||||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||||
|
|
||||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
def _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)
|
response = self._sync_obj.get(path, recursive=True)
|
||||||
if not response:
|
if not response:
|
||||||
return Cluster.empty()
|
return Cluster.empty()
|
||||||
nodes = {key[len(path):]: value for key, value in response.items()}
|
nodes = {key[len(path):]: value for key, value in response.items()}
|
||||||
return self._cluster_from_nodes(nodes)
|
return self._cluster_from_nodes(nodes)
|
||||||
|
|
||||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
def _citus_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)
|
clusters: Dict[int, Dict[str, Any]] = defaultdict(dict)
|
||||||
response = self._sync_obj.get(path, recursive=True)
|
response = self._sync_obj.get(path, recursive=True)
|
||||||
for key, value in (response or {}).items():
|
for key, value in (response or {}).items():
|
||||||
key = key[len(path):].split('/', 1)
|
key = key[len(path):].split('/', 1)
|
||||||
if len(key) == 2 and self._mpp.group_re.match(key[0]):
|
if len(key) == 2 and citus_group_re.match(key[0]):
|
||||||
clusters[int(key[0])][key[1]] = value
|
clusters[int(key[0])][key[1]] = value
|
||||||
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
|
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 kazoo.security import ACL, make_acl
|
||||||
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
|
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||||
|
TimelineHistory, citus_group_re
|
||||||
from ..exceptions import DCSError
|
from ..exceptions import DCSError
|
||||||
from ..postgresql.mpp import AbstractMPP
|
|
||||||
from ..utils import deep_compare
|
from ..utils import deep_compare
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
@@ -87,8 +87,8 @@ class PatroniKazooClient(KazooClient):
|
|||||||
|
|
||||||
class ZooKeeper(AbstractDCS):
|
class ZooKeeper(AbstractDCS):
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
super(ZooKeeper, self).__init__(config, mpp)
|
super(ZooKeeper, self).__init__(config)
|
||||||
|
|
||||||
hosts: Union[str, List[str]] = config.get('hosts', [])
|
hosts: Union[str, List[str]] = config.get('hosts', [])
|
||||||
if isinstance(hosts, list):
|
if isinstance(hosts, list):
|
||||||
@@ -115,8 +115,7 @@ class ZooKeeper(AbstractDCS):
|
|||||||
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
|
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
|
||||||
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
|
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||||
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
|
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
|
||||||
deadline=config['retry_timeout'], sleep_func=time.sleep),
|
deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs)
|
||||||
auth_data=list(config.get('auth_data', {}).items()), **kwargs)
|
|
||||||
|
|
||||||
self.__last_member_data: Optional[Dict[str, Any]] = None
|
self.__last_member_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
@@ -214,13 +213,7 @@ class ZooKeeper(AbstractDCS):
|
|||||||
members.append(self.member(member, *data))
|
members.append(self.member(member, *data))
|
||||||
return members
|
return members
|
||||||
|
|
||||||
def _postgresql_cluster_loader(self, path: str) -> Cluster:
|
def _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))
|
nodes = set(self.get_children(path))
|
||||||
|
|
||||||
# get initialize flag
|
# get initialize flag
|
||||||
@@ -264,17 +257,11 @@ class ZooKeeper(AbstractDCS):
|
|||||||
|
|
||||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||||
|
|
||||||
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
def _citus_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] = {}
|
ret: Dict[int, Cluster] = {}
|
||||||
for node in self.get_children(path):
|
for node in self.get_children(path):
|
||||||
if self._mpp.group_re.match(node):
|
if citus_group_re.match(node):
|
||||||
ret[int(node)] = self._postgresql_cluster_loader(path + node + '/')
|
ret[int(node)] = self._cluster_loader(path + node + '/')
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def _load_cluster(
|
def _load_cluster(
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
"""Helper functions to search for implementations of specific abstract interface in a package."""
|
|
||||||
import importlib
|
|
||||||
import inspect
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import pkgutil
|
|
||||||
import sys
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Type, TypeVar, Union
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def iter_modules(package: str) -> List[str]:
|
|
||||||
"""Get names of modules from *package*, depending on execution environment.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
If being packaged with PyInstaller, modules aren't discoverable dynamically by scanning source directory because
|
|
||||||
:class:`importlib.machinery.FrozenImporter` doesn't implement :func:`iter_modules`. But it is still possible to
|
|
||||||
find all potential modules by iterating through ``toc``, which contains list of all "frozen" resources.
|
|
||||||
|
|
||||||
:param package: a package name to search modules in, e.g. ``patroni.dcs``.
|
|
||||||
|
|
||||||
:returns: list of known module names with absolute python module path namespace, e.g. ``patroni.dcs.etcd``.
|
|
||||||
"""
|
|
||||||
module_prefix = package + '.'
|
|
||||||
|
|
||||||
if getattr(sys, 'frozen', False):
|
|
||||||
toc: Set[str] = set()
|
|
||||||
# dirname may contain a few dots, which causes pkgutil.iter_importers()
|
|
||||||
# to misinterpret the path as a package name. This can be avoided
|
|
||||||
# altogether by not passing a path at all, because PyInstaller's
|
|
||||||
# FrozenImporter is a singleton and registered as top-level finder.
|
|
||||||
for importer in pkgutil.iter_importers():
|
|
||||||
if hasattr(importer, 'toc'):
|
|
||||||
toc |= getattr(importer, 'toc')
|
|
||||||
dots = module_prefix.count('.') # search for modules only on the same level
|
|
||||||
return [module for module in toc if module.startswith(module_prefix) and module.count('.') == dots]
|
|
||||||
|
|
||||||
# here we are making an assumption that the package which is calling this function is already imported
|
|
||||||
pkg_file = sys.modules[package].__file__
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
|
||||||
assert isinstance(pkg_file, str)
|
|
||||||
return [name for _, name, is_pkg in pkgutil.iter_modules([os.path.dirname(pkg_file)], module_prefix) if not is_pkg]
|
|
||||||
|
|
||||||
|
|
||||||
ClassType = TypeVar("ClassType")
|
|
||||||
|
|
||||||
|
|
||||||
def find_class_in_module(module: ModuleType, cls_type: Type[ClassType]) -> Optional[Type[ClassType]]:
|
|
||||||
"""Try to find the implementation of *cls_type* class interface in *module* matching the *module* name.
|
|
||||||
|
|
||||||
:param module: imported module.
|
|
||||||
:param cls_type: a class type we are looking for.
|
|
||||||
|
|
||||||
:returns: class with a name matching the name of *module* that implements *cls_type* or ``None`` if not found.
|
|
||||||
"""
|
|
||||||
module_name = module.__name__.rpartition('.')[2]
|
|
||||||
return next(
|
|
||||||
(obj for obj_name, obj in module.__dict__.items()
|
|
||||||
if (obj_name.lower() == module_name
|
|
||||||
and inspect.isclass(obj) and issubclass(obj, cls_type))),
|
|
||||||
None)
|
|
||||||
|
|
||||||
|
|
||||||
def iter_classes(
|
|
||||||
package: str, cls_type: Type[ClassType],
|
|
||||||
config: Optional[Union['Config', Dict[str, Any]]] = None
|
|
||||||
) -> Iterator[Tuple[str, Type[ClassType]]]:
|
|
||||||
"""Attempt to import modules and find implementations of *cls_type* that are present in the given configuration.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
If a module successfully imports we can assume that all its requirements are installed.
|
|
||||||
|
|
||||||
:param package: a package name to search modules in, e.g. ``patroni.dcs``.
|
|
||||||
:param cls_type: a class type we are looking for.
|
|
||||||
:param config: configuration information with possible module names as keys. If given, only attempt to import
|
|
||||||
modules defined in the configuration. Else, if ``None``, attempt to import any supported module.
|
|
||||||
|
|
||||||
:yields: a tuple containing the module ``name`` and the imported class object.
|
|
||||||
"""
|
|
||||||
for mod_name in iter_modules(package):
|
|
||||||
name = mod_name.rpartition('.')[2]
|
|
||||||
if config is None or name in config:
|
|
||||||
try:
|
|
||||||
module = importlib.import_module(mod_name)
|
|
||||||
module_cls = find_class_in_module(module, cls_type)
|
|
||||||
if module_cls:
|
|
||||||
yield name, module_cls
|
|
||||||
except ImportError:
|
|
||||||
logger.log(logging.DEBUG if config is not None else logging.INFO,
|
|
||||||
'Failed to import %s', mod_name)
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
"""Implements *global_config* facilities.
|
|
||||||
|
|
||||||
The :class:`GlobalConfig` object is instantiated on import and replaces
|
|
||||||
``patroni.global_config`` module in :data:`sys.modules`, what allows to use
|
|
||||||
its properties and methods like they were module variables and functions.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
|
|
||||||
|
|
||||||
from .utils import parse_bool, parse_int
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
|
||||||
from .dcs import Cluster
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(mod: types.ModuleType, name: str) -> Any:
|
|
||||||
"""This function exists just to make pyright happy.
|
|
||||||
|
|
||||||
Without it pyright complains about access to unknown members of global_config module.
|
|
||||||
"""
|
|
||||||
return getattr(sys.modules[__name__], name) # pragma: no cover
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalConfig(types.ModuleType):
|
|
||||||
"""A class that wraps global configuration and provides convenient methods to access/check values."""
|
|
||||||
|
|
||||||
__file__ = __file__ # just to make unittest and pytest happy
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Initialize :class:`GlobalConfig` object."""
|
|
||||||
super().__init__(__name__)
|
|
||||||
self.__config = {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _cluster_has_valid_config(cluster: Optional['Cluster']) -> bool:
|
|
||||||
"""Check if provided *cluster* object has a valid global configuration.
|
|
||||||
|
|
||||||
:param cluster: the currently known cluster state from DCS.
|
|
||||||
|
|
||||||
:returns: ``True`` if provided *cluster* object has a valid global configuration, otherwise ``False``.
|
|
||||||
"""
|
|
||||||
return bool(cluster and cluster.config and cluster.config.modify_version)
|
|
||||||
|
|
||||||
def update(self, cluster: Optional['Cluster'], default: Optional[Dict[str, Any]] = None) -> None:
|
|
||||||
"""Update with the new global configuration from the :class:`Cluster` object view.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
Update happens in-place and is executed only from the main heartbeat thread.
|
|
||||||
|
|
||||||
:param cluster: the currently known cluster state from DCS.
|
|
||||||
:param default: default configuration, which will be used if there is no valid *cluster.config*.
|
|
||||||
"""
|
|
||||||
# Try to protect from the case when DCS was wiped out
|
|
||||||
if self._cluster_has_valid_config(cluster):
|
|
||||||
self.__config = cluster.config.data # pyright: ignore [reportOptionalMemberAccess]
|
|
||||||
elif default:
|
|
||||||
self.__config = default
|
|
||||||
|
|
||||||
def from_cluster(self, cluster: Optional['Cluster']) -> 'GlobalConfig':
|
|
||||||
"""Return :class:`GlobalConfig` instance from the provided :class:`Cluster` object view.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
If the provided *cluster* object doesn't have a valid global configuration we return
|
|
||||||
the last known valid state of the :class:`GlobalConfig` object.
|
|
||||||
|
|
||||||
This method is used when we need to have the most up-to-date values in the global configuration,
|
|
||||||
but we don't want to update the global object.
|
|
||||||
|
|
||||||
:param cluster: the currently known cluster state from DCS.
|
|
||||||
|
|
||||||
:returns: :class:`GlobalConfig` object.
|
|
||||||
"""
|
|
||||||
if not self._cluster_has_valid_config(cluster):
|
|
||||||
return self
|
|
||||||
|
|
||||||
ret = GlobalConfig()
|
|
||||||
ret.update(cluster)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def get(self, name: str) -> Any:
|
|
||||||
"""Gets global configuration value by *name*.
|
|
||||||
|
|
||||||
:param name: parameter name.
|
|
||||||
|
|
||||||
:returns: configuration value or ``None`` if it is missing.
|
|
||||||
"""
|
|
||||||
return self.__config.get(name)
|
|
||||||
|
|
||||||
def check_mode(self, mode: str) -> bool:
|
|
||||||
"""Checks whether the certain parameter is enabled.
|
|
||||||
|
|
||||||
:param mode: parameter name, e.g. ``synchronous_mode``, ``failsafe_mode``, ``pause``, ``check_timeline``, and
|
|
||||||
so on.
|
|
||||||
|
|
||||||
:returns: ``True`` if parameter *mode* is enabled in the global configuration.
|
|
||||||
"""
|
|
||||||
return bool(parse_bool(self.__config.get(mode)))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_paused(self) -> bool:
|
|
||||||
"""``True`` if cluster is in maintenance mode."""
|
|
||||||
return self.check_mode('pause')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_quorum_commit_mode(self) -> bool:
|
|
||||||
""":returns: ``True`` if quorum commit replication is requested"""
|
|
||||||
return str(self.get('synchronous_mode')).lower() == 'quorum'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_synchronous_mode(self) -> bool:
|
|
||||||
"""``True`` if synchronous replication is requested and it is not a standby cluster config."""
|
|
||||||
return (self.check_mode('synchronous_mode') is True or self.is_quorum_commit_mode) \
|
|
||||||
and not self.is_standby_cluster
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_synchronous_mode_strict(self) -> bool:
|
|
||||||
"""``True`` if at least one synchronous node is required."""
|
|
||||||
return self.check_mode('synchronous_mode_strict')
|
|
||||||
|
|
||||||
def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]:
|
|
||||||
"""Get ``standby_cluster`` configuration.
|
|
||||||
|
|
||||||
:returns: a copy of ``standby_cluster`` configuration.
|
|
||||||
"""
|
|
||||||
return deepcopy(self.get('standby_cluster'))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_standby_cluster(self) -> bool:
|
|
||||||
"""``True`` if global configuration has a valid ``standby_cluster`` section."""
|
|
||||||
config = self.get_standby_cluster_config()
|
|
||||||
return isinstance(config, dict) and\
|
|
||||||
bool(config.get('host') or config.get('port') or config.get('restore_command'))
|
|
||||||
|
|
||||||
def get_int(self, name: str, default: int = 0) -> int:
|
|
||||||
"""Gets current value of *name* from the global configuration and try to return it as :class:`int`.
|
|
||||||
|
|
||||||
:param name: name of the parameter.
|
|
||||||
:param default: default value if *name* is not in the configuration or invalid.
|
|
||||||
|
|
||||||
:returns: currently configured value of *name* from the global configuration or *default* if it is not set or
|
|
||||||
invalid.
|
|
||||||
"""
|
|
||||||
ret = parse_int(self.get(name))
|
|
||||||
return default if ret is None else ret
|
|
||||||
|
|
||||||
@property
|
|
||||||
def min_synchronous_nodes(self) -> int:
|
|
||||||
"""The minimum number of synchronous nodes based on whether ``synchronous_mode_strict`` is enabled or not."""
|
|
||||||
return 1 if self.is_synchronous_mode_strict else 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def synchronous_node_count(self) -> int:
|
|
||||||
"""Currently configured value of ``synchronous_node_count`` from the global configuration.
|
|
||||||
|
|
||||||
Assume ``1`` if it is not set or invalid.
|
|
||||||
"""
|
|
||||||
return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def maximum_lag_on_failover(self) -> int:
|
|
||||||
"""Currently configured value of ``maximum_lag_on_failover`` from the global configuration.
|
|
||||||
|
|
||||||
Assume ``1048576`` if it is not set or invalid.
|
|
||||||
"""
|
|
||||||
return self.get_int('maximum_lag_on_failover', 1048576)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def maximum_lag_on_syncnode(self) -> int:
|
|
||||||
"""Currently configured value of ``maximum_lag_on_syncnode`` from the global configuration.
|
|
||||||
|
|
||||||
Assume ``-1`` if it is not set or invalid.
|
|
||||||
"""
|
|
||||||
return self.get_int('maximum_lag_on_syncnode', -1)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def primary_start_timeout(self) -> int:
|
|
||||||
"""Currently configured value of ``primary_start_timeout`` from the global configuration.
|
|
||||||
|
|
||||||
Assume ``300`` if it is not set or invalid.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
``master_start_timeout`` is still supported to keep backward compatibility.
|
|
||||||
"""
|
|
||||||
default = 300
|
|
||||||
return self.get_int('primary_start_timeout', default)\
|
|
||||||
if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def primary_stop_timeout(self) -> int:
|
|
||||||
"""Currently configured value of ``primary_stop_timeout`` from the global configuration.
|
|
||||||
|
|
||||||
Assume ``0`` if it is not set or invalid.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
``master_stop_timeout`` is still supported to keep backward compatibility.
|
|
||||||
"""
|
|
||||||
default = 0
|
|
||||||
return self.get_int('primary_stop_timeout', default)\
|
|
||||||
if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ignore_slots_matchers(self) -> List[Dict[str, Any]]:
|
|
||||||
"""Currently configured value of ``ignore_slots`` from the global configuration.
|
|
||||||
|
|
||||||
Assume an empty :class:`list` if not set.
|
|
||||||
"""
|
|
||||||
return self.get('ignore_slots') or []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def max_timelines_history(self) -> int:
|
|
||||||
"""Currently configured value of ``max_timelines_history`` from the global configuration.
|
|
||||||
|
|
||||||
Assume ``0`` if not set or invalid.
|
|
||||||
"""
|
|
||||||
return self.get_int('max_timelines_history', 0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def use_slots(self) -> bool:
|
|
||||||
"""``True`` if cluster is configured to use replication slots."""
|
|
||||||
return bool(parse_bool((self.get('postgresql') or {}).get('use_slots', True)))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def permanent_slots(self) -> Dict[str, Any]:
|
|
||||||
"""Dictionary of permanent slots information from the global configuration."""
|
|
||||||
return deepcopy(self.get('permanent_replication_slots')
|
|
||||||
or self.get('permanent_slots')
|
|
||||||
or self.get('slots')
|
|
||||||
or {})
|
|
||||||
|
|
||||||
|
|
||||||
sys.modules[__name__] = GlobalConfig()
|
|
||||||
+110
-313
@@ -10,17 +10,16 @@ from multiprocessing.pool import ThreadPool
|
|||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
from typing import Any, Callable, Collection, Dict, List, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
from . import global_config, psycopg
|
from . import psycopg
|
||||||
from .__main__ import Patroni
|
from .__main__ import Patroni
|
||||||
from .async_executor import AsyncExecutor, CriticalTask
|
from .async_executor import AsyncExecutor, CriticalTask
|
||||||
from .collections import CaseInsensitiveSet
|
from .collections import CaseInsensitiveSet
|
||||||
from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember, Status, SyncState, slot_name_from_member_name
|
from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember, Status, slot_name_from_member_name
|
||||||
from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException
|
from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException
|
||||||
from .postgresql.callback_executor import CallbackAction
|
from .postgresql.callback_executor import CallbackAction
|
||||||
from .postgresql.misc import postgres_version_to_int
|
from .postgresql.misc import postgres_version_to_int
|
||||||
from .postgresql.postmaster import PostmasterProcess
|
from .postgresql.postmaster import PostmasterProcess
|
||||||
from .postgresql.rewind import Rewind
|
from .postgresql.rewind import Rewind
|
||||||
from .quorum import QuorumStateResolver
|
|
||||||
from .tags import Tags
|
from .tags import Tags
|
||||||
from .utils import polling_loop, tzutc
|
from .utils import polling_loop, tzutc
|
||||||
|
|
||||||
@@ -157,12 +156,12 @@ class Ha(object):
|
|||||||
self._rewind = Rewind(self.state_handler)
|
self._rewind = Rewind(self.state_handler)
|
||||||
self.dcs = patroni.dcs
|
self.dcs = patroni.dcs
|
||||||
self.cluster = Cluster.empty()
|
self.cluster = Cluster.empty()
|
||||||
|
self.global_config = self.patroni.config.get_global_config(None)
|
||||||
self.old_cluster = Cluster.empty()
|
self.old_cluster = Cluster.empty()
|
||||||
self._leader_expiry = 0
|
self._leader_expiry = 0
|
||||||
self._leader_expiry_lock = RLock()
|
self._leader_expiry_lock = RLock()
|
||||||
self._failsafe = Failsafe(patroni.dcs)
|
self._failsafe = Failsafe(patroni.dcs)
|
||||||
self._was_paused = False
|
self._was_paused = False
|
||||||
self._promote_timestamp = 0
|
|
||||||
self._leader_timeline = None
|
self._leader_timeline = None
|
||||||
self.recovering = False
|
self.recovering = False
|
||||||
self._async_response = CriticalTask()
|
self._async_response = CriticalTask()
|
||||||
@@ -177,7 +176,7 @@ class Ha(object):
|
|||||||
# Count of concurrent sync disabling requests. Value above zero means that we don't want to be synchronous
|
# 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.
|
# standby. Changes protected by _member_state_lock.
|
||||||
self._disable_sync = 0
|
self._disable_sync = 0
|
||||||
# Remember the last known member role and state written to the DCS in order to notify MPP coordinator
|
# Remember the last known member role and state written to the DCS in order to notify Citus coordinator
|
||||||
self._last_state = None
|
self._last_state = None
|
||||||
|
|
||||||
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
|
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
|
||||||
@@ -187,25 +186,22 @@ class Ha(object):
|
|||||||
# used only in backoff after failing a pre_promote script
|
# used only in backoff after failing a pre_promote script
|
||||||
self._released_leader_key_timestamp = 0
|
self._released_leader_key_timestamp = 0
|
||||||
|
|
||||||
# Initialize global config
|
|
||||||
global_config.update(None, self.patroni.config.dynamic_configuration)
|
|
||||||
|
|
||||||
def primary_stop_timeout(self) -> Union[int, None]:
|
def primary_stop_timeout(self) -> Union[int, None]:
|
||||||
""":returns: "primary_stop_timeout" from the global configuration or `None` when not in synchronous mode."""
|
""":returns: "primary_stop_timeout" from the global configuration or `None` when not in synchronous mode."""
|
||||||
ret = global_config.primary_stop_timeout
|
ret = self.global_config.primary_stop_timeout
|
||||||
return ret if ret > 0 and self.is_synchronous_mode() else None
|
return ret if ret > 0 and self.is_synchronous_mode() else None
|
||||||
|
|
||||||
def is_paused(self) -> bool:
|
def is_paused(self) -> bool:
|
||||||
""":returns: `True` if in maintenance mode."""
|
""":returns: `True` if in maintenance mode."""
|
||||||
return global_config.is_paused
|
return self.global_config.is_paused
|
||||||
|
|
||||||
def check_timeline(self) -> bool:
|
def check_timeline(self) -> bool:
|
||||||
""":returns: `True` if should check whether the timeline is latest during the leader race."""
|
""":returns: `True` if should check whether the timeline is latest during the leader race."""
|
||||||
return global_config.check_mode('check_timeline')
|
return self.global_config.check_mode('check_timeline')
|
||||||
|
|
||||||
def is_standby_cluster(self) -> bool:
|
def is_standby_cluster(self) -> bool:
|
||||||
""":returns: `True` if global configuration has a valid "standby_cluster" section."""
|
""":returns: `True` if global configuration has a valid "standby_cluster" section."""
|
||||||
return global_config.is_standby_cluster
|
return self.global_config.is_standby_cluster
|
||||||
|
|
||||||
def is_leader(self) -> bool:
|
def is_leader(self) -> bool:
|
||||||
""":returns: `True` if the current node is the leader, based on expiration set when it last held the key."""
|
""":returns: `True` if the current node is the leader, based on expiration set when it last held the key."""
|
||||||
@@ -222,8 +218,6 @@ class Ha(object):
|
|||||||
"""
|
"""
|
||||||
with self._leader_expiry_lock:
|
with self._leader_expiry_lock:
|
||||||
self._leader_expiry = time.time() + self.dcs.ttl if value else 0
|
self._leader_expiry = time.time() + self.dcs.ttl if value else 0
|
||||||
if not value:
|
|
||||||
self._promote_timestamp = 0
|
|
||||||
|
|
||||||
def sync_mode_is_active(self) -> bool:
|
def sync_mode_is_active(self) -> bool:
|
||||||
"""Check whether synchronous replication is requested and already active.
|
"""Check whether synchronous replication is requested and already active.
|
||||||
@@ -232,13 +226,6 @@ class Ha(object):
|
|||||||
"""
|
"""
|
||||||
return self.is_synchronous_mode() and not self.cluster.sync.is_empty
|
return self.is_synchronous_mode() and not self.cluster.sync.is_empty
|
||||||
|
|
||||||
def quorum_commit_mode_is_active(self) -> bool:
|
|
||||||
"""Checks whether quorum replication is requested and already active.
|
|
||||||
|
|
||||||
:returns: ``True`` if the primary already put its name into the ``/sync`` in DCS.
|
|
||||||
"""
|
|
||||||
return self.is_quorum_commit_mode() and not self.cluster.sync.is_empty
|
|
||||||
|
|
||||||
def _get_failover_action_name(self) -> str:
|
def _get_failover_action_name(self) -> str:
|
||||||
"""Return the currently requested manual failover action name or the default ``failover``.
|
"""Return the currently requested manual failover action name or the default ``failover``.
|
||||||
|
|
||||||
@@ -308,8 +295,9 @@ class Ha(object):
|
|||||||
try:
|
try:
|
||||||
last_lsn = self.state_handler.last_operation()
|
last_lsn = self.state_handler.last_operation()
|
||||||
slots = self.cluster.filter_permanent_slots(
|
slots = self.cluster.filter_permanent_slots(
|
||||||
self.state_handler,
|
{**self.state_handler.slots(), slot_name_from_member_name(self.state_handler.name): last_lsn},
|
||||||
{**self.state_handler.slots(), slot_name_from_member_name(self.state_handler.name): last_lsn})
|
self.is_standby_cluster(),
|
||||||
|
self.state_handler.major_version)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Exception when called state_handler.last_operation()')
|
logger.exception('Exception when called state_handler.last_operation()')
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
@@ -340,26 +328,20 @@ class Ha(object):
|
|||||||
tags['nosync'] = True
|
tags['nosync'] = True
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
def notify_mpp_coordinator(self, event: str) -> None:
|
def notify_citus_coordinator(self, event: str) -> None:
|
||||||
"""Send an event to the MPP coordinator.
|
if self.state_handler.citus_handler.is_worker():
|
||||||
|
coordinator = self.dcs.get_citus_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:
|
if coordinator and coordinator.leader and coordinator.leader.conn_url:
|
||||||
try:
|
try:
|
||||||
data = {'type': event,
|
data = {'type': event,
|
||||||
'group': mpp_handler.group,
|
'group': self.state_handler.citus_handler.group(),
|
||||||
'leader': self.state_handler.name,
|
'leader': self.state_handler.name,
|
||||||
'timeout': self.dcs.ttl,
|
'timeout': self.dcs.ttl,
|
||||||
'cooldown': self.patroni.config['retry_timeout']}
|
'cooldown': self.patroni.config['retry_timeout']}
|
||||||
timeout = self.dcs.ttl if event == 'before_demote' else 2
|
timeout = self.dcs.ttl if event == 'before_demote' else 2
|
||||||
endpoint = 'citus' if mpp_handler.type == 'Citus' else 'mpp'
|
self.patroni.request(coordinator.leader.member, 'post', 'citus', data, timeout=timeout, retries=0)
|
||||||
self.patroni.request(coordinator.leader.member, 'post', endpoint, data, timeout=timeout, retries=0)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('Request to %s coordinator leader %s %s failed: %r', mpp_handler.type,
|
logger.warning('Request to Citus coordinator leader %s %s failed: %r',
|
||||||
coordinator.leader.name, coordinator.leader.member.api_url, e)
|
coordinator.leader.name, coordinator.leader.member.api_url, e)
|
||||||
|
|
||||||
def touch_member(self) -> bool:
|
def touch_member(self) -> bool:
|
||||||
@@ -381,9 +363,8 @@ class Ha(object):
|
|||||||
tags = self.get_effective_tags()
|
tags = self.get_effective_tags()
|
||||||
if tags:
|
if tags:
|
||||||
data['tags'] = tags
|
data['tags'] = tags
|
||||||
if self.state_handler.pending_restart_reason:
|
if self.state_handler.pending_restart:
|
||||||
data['pending_restart'] = True
|
data['pending_restart'] = True
|
||||||
data['pending_restart_reason'] = dict(self.state_handler.pending_restart_reason)
|
|
||||||
if self._async_executor.scheduled_action in (None, 'promote') \
|
if self._async_executor.scheduled_action in (None, 'promote') \
|
||||||
and data['state'] in ['running', 'restarting', 'starting']:
|
and data['state'] in ['running', 'restarting', 'starting']:
|
||||||
try:
|
try:
|
||||||
@@ -423,7 +404,7 @@ class Ha(object):
|
|||||||
if ret:
|
if ret:
|
||||||
new_state = (data['state'], {'master': 'primary'}.get(data['role'], data['role']))
|
new_state = (data['state'], {'master': 'primary'}.get(data['role'], data['role']))
|
||||||
if self._last_state != new_state and new_state == ('running', 'primary'):
|
if self._last_state != new_state and new_state == ('running', 'primary'):
|
||||||
self.notify_mpp_coordinator('after_promote')
|
self.notify_citus_coordinator('after_promote')
|
||||||
self._last_state = new_state
|
self._last_state = new_state
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@@ -469,7 +450,7 @@ class Ha(object):
|
|||||||
return ret or 'trying to bootstrap {0}'.format(msg)
|
return ret or 'trying to bootstrap {0}'.format(msg)
|
||||||
|
|
||||||
# no leader, but configuration may allowed replica creation using backup tools
|
# no leader, but configuration may allowed replica creation using backup tools
|
||||||
create_replica_methods = global_config.get_standby_cluster_config().get('create_replica_methods', []) \
|
create_replica_methods = self.global_config.get_standby_cluster_config().get('create_replica_methods', []) \
|
||||||
if self.is_standby_cluster() else None
|
if self.is_standby_cluster() else None
|
||||||
can_bootstrap = self.state_handler.can_create_replica_without_replication_connection(create_replica_methods)
|
can_bootstrap = self.state_handler.can_create_replica_without_replication_connection(create_replica_methods)
|
||||||
concurrent_bootstrap = self.cluster.initialize == ""
|
concurrent_bootstrap = self.cluster.initialize == ""
|
||||||
@@ -544,7 +525,7 @@ class Ha(object):
|
|||||||
:returns: action message, describing what was performed.
|
:returns: action message, describing what was performed.
|
||||||
"""
|
"""
|
||||||
if self.has_lock() and self.update_lock():
|
if self.has_lock() and self.update_lock():
|
||||||
timeout = global_config.primary_start_timeout
|
timeout = self.global_config.primary_start_timeout
|
||||||
if timeout == 0:
|
if timeout == 0:
|
||||||
# We are requested to prefer failing over to restarting primary. But see first if there
|
# We are requested to prefer failing over to restarting primary. But see first if there
|
||||||
# is anyone to fail over to.
|
# is anyone to fail over to.
|
||||||
@@ -621,12 +602,9 @@ class Ha(object):
|
|||||||
|
|
||||||
:returns: the node which we should be replicating from.
|
:returns: the node which we should be replicating from.
|
||||||
"""
|
"""
|
||||||
# nostream is set, the node must not use WAL streaming
|
|
||||||
if self.patroni.nostream:
|
|
||||||
return None
|
|
||||||
# The standby leader or when there is no standby leader we want to follow
|
# The standby leader or when there is no standby leader we want to follow
|
||||||
# the remote member, except when there is no standby leader in pause.
|
# the remote member, except when there is no standby leader in pause.
|
||||||
elif self.is_standby_cluster() \
|
if self.is_standby_cluster() \
|
||||||
and (cluster.leader and cluster.leader.name and cluster.leader.name == self.state_handler.name
|
and (cluster.leader and cluster.leader.name and cluster.leader.name == self.state_handler.name
|
||||||
or cluster.is_unlocked() and not self.is_paused()):
|
or cluster.is_unlocked() and not self.is_paused()):
|
||||||
node_to_follow = self.get_remote_member()
|
node_to_follow = self.get_remote_member()
|
||||||
@@ -644,7 +622,7 @@ class Ha(object):
|
|||||||
for param in params: # It is highly unlikely to happen, but we want to protect from the case
|
for param in params: # It is highly unlikely to happen, but we want to protect from the case
|
||||||
node_to_follow.data.pop(param, None) # when above-mentioned params came from outside.
|
node_to_follow.data.pop(param, None) # when above-mentioned params came from outside.
|
||||||
if self.is_standby_cluster():
|
if self.is_standby_cluster():
|
||||||
standby_config = global_config.get_standby_cluster_config()
|
standby_config = self.global_config.get_standby_cluster_config()
|
||||||
node_to_follow.data.update({p: standby_config[p] for p in params if standby_config.get(p)})
|
node_to_follow.data.update({p: standby_config[p] for p in params if standby_config.get(p)})
|
||||||
|
|
||||||
return node_to_follow
|
return node_to_follow
|
||||||
@@ -706,105 +684,14 @@ class Ha(object):
|
|||||||
|
|
||||||
def is_synchronous_mode(self) -> bool:
|
def is_synchronous_mode(self) -> bool:
|
||||||
""":returns: `True` if synchronous replication is requested."""
|
""":returns: `True` if synchronous replication is requested."""
|
||||||
return global_config.is_synchronous_mode
|
return self.global_config.is_synchronous_mode
|
||||||
|
|
||||||
def is_quorum_commit_mode(self) -> bool:
|
|
||||||
"""``True`` if quorum commit replication is requested and "supported"."""
|
|
||||||
return global_config.is_quorum_commit_mode and self.state_handler.supports_multiple_sync
|
|
||||||
|
|
||||||
def is_failsafe_mode(self) -> bool:
|
def is_failsafe_mode(self) -> bool:
|
||||||
""":returns: `True` if failsafe_mode is enabled in global configuration."""
|
""":returns: `True` if failsafe_mode is enabled in global configuration."""
|
||||||
return global_config.check_mode('failsafe_mode')
|
return self.global_config.check_mode('failsafe_mode')
|
||||||
|
|
||||||
def _maybe_enable_synchronous_mode(self) -> Optional[SyncState]:
|
def process_sync_replication(self) -> None:
|
||||||
"""Explicitly enable synchronous mode if not yet enabled.
|
"""Process synchronous standby beahvior.
|
||||||
|
|
||||||
We are trying to solve a corner case: synchronous mode needs to be explicitly enabled
|
|
||||||
by updating the ``/sync`` key with the current leader name and empty members. In opposite
|
|
||||||
case it will never be automatically enabled if there are no eligible candidates.
|
|
||||||
|
|
||||||
:returns: the latest version of :class:`~patroni.dcs.SyncState` object.
|
|
||||||
"""
|
|
||||||
sync = self.cluster.sync
|
|
||||||
if sync.is_empty:
|
|
||||||
sync = self.dcs.write_sync_state(self.state_handler.name, None, 0, version=sync.version)
|
|
||||||
if sync:
|
|
||||||
logger.info("Enabled synchronous replication")
|
|
||||||
else:
|
|
||||||
logger.warning("Updating sync state failed")
|
|
||||||
return sync
|
|
||||||
|
|
||||||
def disable_synchronous_replication(self) -> None:
|
|
||||||
"""Cleans up /sync key in DCS if synchronous replication is disabled."""
|
|
||||||
if not self.cluster.sync.is_empty and self.dcs.delete_sync_state(version=self.cluster.sync.version):
|
|
||||||
logger.info("Disabled synchronous replication")
|
|
||||||
self.state_handler.sync_handler.set_synchronous_standby_names(CaseInsensitiveSet())
|
|
||||||
|
|
||||||
def _process_quorum_replication(self) -> None:
|
|
||||||
"""Process synchronous replication state when quorum commit is requested.
|
|
||||||
|
|
||||||
Synchronous standbys are registered in two places: ``postgresql.conf`` and DCS. The order of updating them must
|
|
||||||
keep the invariant that ``quorum + sync >= len(set(quorum pool)|set(sync pool))``. This is done using
|
|
||||||
:class:`QuorumStateResolver` that given a current state and set of desired synchronous nodes and replication
|
|
||||||
level outputs changes to DCS and synchronous replication in correct order to reach the desired state.
|
|
||||||
In case any of those steps causes an error we can just bail out and let next iteration rediscover the state
|
|
||||||
and retry necessary transitions.
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
min_sync = global_config.min_synchronous_nodes
|
|
||||||
sync_wanted = global_config.synchronous_node_count
|
|
||||||
|
|
||||||
sync = self._maybe_enable_synchronous_mode()
|
|
||||||
if not sync or not sync.leader:
|
|
||||||
return
|
|
||||||
|
|
||||||
leader = sync.leader
|
|
||||||
|
|
||||||
def _check_timeout(offset: float = 0) -> bool:
|
|
||||||
return time.time() - start_time + offset >= self.dcs.loop_wait
|
|
||||||
|
|
||||||
while True:
|
|
||||||
transition = 'break' # we need define transition value if `QuorumStateResolver` produced no changes
|
|
||||||
sync_state = self.state_handler.sync_handler.current_state(self.cluster)
|
|
||||||
for transition, leader, num, nodes in QuorumStateResolver(leader=leader,
|
|
||||||
quorum=sync.quorum,
|
|
||||||
voters=sync.voters,
|
|
||||||
numsync=sync_state.numsync,
|
|
||||||
sync=sync_state.sync,
|
|
||||||
numsync_confirmed=sync_state.numsync_confirmed,
|
|
||||||
active=sync_state.active,
|
|
||||||
sync_wanted=sync_wanted,
|
|
||||||
leader_wanted=self.state_handler.name):
|
|
||||||
if _check_timeout():
|
|
||||||
return
|
|
||||||
|
|
||||||
if transition == 'quorum':
|
|
||||||
logger.info("Setting leader to %s, quorum to %d of %d (%s)",
|
|
||||||
leader, num, len(nodes), ", ".join(sorted(nodes)))
|
|
||||||
sync = self.dcs.write_sync_state(leader, nodes, num, version=sync.version)
|
|
||||||
if not sync:
|
|
||||||
return logger.info('Synchronous replication key updated by someone else.')
|
|
||||||
elif transition == 'sync':
|
|
||||||
logger.info("Setting synchronous replication to %d of %d (%s)",
|
|
||||||
num, len(nodes), ", ".join(sorted(nodes)))
|
|
||||||
# Bump up number of num nodes to meet minimum replication factor. Commits will have to wait until
|
|
||||||
# we have enough nodes to meet replication target.
|
|
||||||
if num < min_sync:
|
|
||||||
logger.warning("Replication factor %d requested, but %d synchronous standbys available."
|
|
||||||
" Commits will be delayed.", min_sync + 1, num)
|
|
||||||
num = min_sync
|
|
||||||
self.state_handler.sync_handler.set_synchronous_standby_names(nodes, num)
|
|
||||||
if transition != 'restart' or _check_timeout(1):
|
|
||||||
return
|
|
||||||
# synchronous_standby_names was transitioned from empty to non-empty and it may take
|
|
||||||
# some time for nodes to become synchronous. In this case we want to restart state machine
|
|
||||||
# hoping that we can update /sync key earlier than in loop_wait seconds.
|
|
||||||
time.sleep(1)
|
|
||||||
self.state_handler.reset_cluster_info_state(None)
|
|
||||||
|
|
||||||
def _process_multisync_replication(self) -> None:
|
|
||||||
"""Process synchronous replication state with one or more sync standbys.
|
|
||||||
|
|
||||||
Synchronous standbys are registered in two places postgresql.conf and DCS. The order of updating them must
|
Synchronous standbys are registered in two places postgresql.conf and DCS. The order of updating them must
|
||||||
be right. The invariant that should be kept is that if a node is primary and sync_standby is set in DCS,
|
be right. The invariant that should be kept is that if a node is primary and sync_standby is set in DCS,
|
||||||
@@ -812,38 +699,40 @@ class Ha(object):
|
|||||||
and then in DCS. When removing, first remove in DCS, then in postgresql.conf. This is so we only consider
|
and then in DCS. When removing, first remove in DCS, then in postgresql.conf. This is so we only consider
|
||||||
promoting standbys that were guaranteed to be replicating synchronously.
|
promoting standbys that were guaranteed to be replicating synchronously.
|
||||||
"""
|
"""
|
||||||
|
if self.is_synchronous_mode():
|
||||||
sync = self._maybe_enable_synchronous_mode()
|
sync = self.cluster.sync
|
||||||
if not sync:
|
if sync.is_empty:
|
||||||
return
|
# corner case: we need to explicitly enable synchronous mode by updating the
|
||||||
|
# ``/sync`` key with the current leader name and empty members. In opposite case
|
||||||
current_state = self.state_handler.sync_handler.current_state(self.cluster)
|
# it will never be automatically enabled if there are not eligible candidates.
|
||||||
picked = current_state.active
|
sync = self.dcs.write_sync_state(self.state_handler.name, None, version=sync.version)
|
||||||
allow_promote = current_state.sync
|
|
||||||
voters = CaseInsensitiveSet(sync.voters)
|
|
||||||
|
|
||||||
if picked == voters and voters != allow_promote:
|
|
||||||
logger.warning('Inconsistent state between synchronous_standby_names = %s and /sync = %s key '
|
|
||||||
'detected, updating synchronous replication key...', list(allow_promote), list(voters))
|
|
||||||
sync = self.dcs.write_sync_state(self.state_handler.name, allow_promote, 0, version=sync.version)
|
|
||||||
if not sync:
|
if not sync:
|
||||||
return logger.warning("Updating sync state failed")
|
return logger.warning("Updating sync state failed")
|
||||||
voters = CaseInsensitiveSet(sync.voters)
|
logger.info("Enabled synchronous replication")
|
||||||
|
|
||||||
if picked == voters:
|
current = CaseInsensitiveSet(sync.members)
|
||||||
return
|
picked, allow_promote = self.state_handler.sync_handler.current_state(self.cluster)
|
||||||
|
|
||||||
|
if picked == current and current != allow_promote:
|
||||||
|
logger.warning('Inconsistent state between synchronous_standby_names = %s and /sync = %s key '
|
||||||
|
'detected, updating synchronous replication key...', list(allow_promote), list(current))
|
||||||
|
sync = self.dcs.write_sync_state(self.state_handler.name, allow_promote, version=sync.version)
|
||||||
|
if not sync:
|
||||||
|
return logger.warning("Updating sync state failed")
|
||||||
|
current = CaseInsensitiveSet(sync.members)
|
||||||
|
|
||||||
|
if picked != current:
|
||||||
# update synchronous standby list in dcs temporarily to point to common nodes in current and picked
|
# update synchronous standby list in dcs temporarily to point to common nodes in current and picked
|
||||||
sync_common = voters & allow_promote
|
sync_common = current & allow_promote
|
||||||
if sync_common != voters:
|
if sync_common != current:
|
||||||
logger.info("Updating synchronous privilege temporarily from %s to %s",
|
logger.info("Updating synchronous privilege temporarily from %s to %s",
|
||||||
list(voters), list(sync_common))
|
list(current), list(sync_common))
|
||||||
sync = self.dcs.write_sync_state(self.state_handler.name, sync_common, 0, version=sync.version)
|
sync = self.dcs.write_sync_state(self.state_handler.name, sync_common, version=sync.version)
|
||||||
if not sync:
|
if not sync:
|
||||||
return logger.info('Synchronous replication key updated by someone else.')
|
return logger.info('Synchronous replication key updated by someone else.')
|
||||||
|
|
||||||
# When strict mode and no suitable replication connections put "*" to synchronous_standby_names
|
# When strict mode and no suitable replication connections put "*" to synchronous_standby_names
|
||||||
if global_config.is_synchronous_mode_strict and not picked:
|
if self.global_config.is_synchronous_mode_strict and not picked:
|
||||||
picked = CaseInsensitiveSet('*')
|
picked = CaseInsensitiveSet('*')
|
||||||
logger.warning("No standbys available!")
|
logger.warning("No standbys available!")
|
||||||
|
|
||||||
@@ -854,67 +743,15 @@ class Ha(object):
|
|||||||
if picked and picked != CaseInsensitiveSet('*') and allow_promote != picked:
|
if picked and picked != CaseInsensitiveSet('*') and allow_promote != picked:
|
||||||
# Wait for PostgreSQL to enable synchronous mode and see if we can immediately set sync_standby
|
# Wait for PostgreSQL to enable synchronous mode and see if we can immediately set sync_standby
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
allow_promote = self.state_handler.sync_handler.current_state(self.cluster).sync
|
_, allow_promote = self.state_handler.sync_handler.current_state(self.cluster)
|
||||||
|
|
||||||
if allow_promote and allow_promote != sync_common:
|
if allow_promote and allow_promote != sync_common:
|
||||||
if self.dcs.write_sync_state(self.state_handler.name, allow_promote, 0, version=sync.version):
|
if not self.dcs.write_sync_state(self.state_handler.name, allow_promote, version=sync.version):
|
||||||
|
return logger.info("Synchronous replication key updated by someone else")
|
||||||
logger.info("Synchronous standby status assigned to %s", list(allow_promote))
|
logger.info("Synchronous standby status assigned to %s", list(allow_promote))
|
||||||
else:
|
else:
|
||||||
logger.info("Synchronous replication key updated by someone else")
|
if not self.cluster.sync.is_empty and self.dcs.delete_sync_state(version=self.cluster.sync.version):
|
||||||
|
logger.info("Disabled synchronous replication")
|
||||||
def process_sync_replication(self) -> None:
|
self.state_handler.sync_handler.set_synchronous_standby_names(CaseInsensitiveSet())
|
||||||
"""Process synchronous replication behavior on the primary."""
|
|
||||||
if self.is_quorum_commit_mode():
|
|
||||||
# The synchronous_standby_names was adjusted right before promote.
|
|
||||||
# After that, when postgres has become a primary, we need to reflect this change
|
|
||||||
# in the /sync key. Further changes of synchronous_standby_names and /sync key should
|
|
||||||
# be postponed for `loop_wait` seconds, to give a chance to some replicas to start streaming.
|
|
||||||
# In opposite case the /sync key will end up without synchronous nodes.
|
|
||||||
if self.state_handler.is_primary():
|
|
||||||
if self._promote_timestamp == 0 or time.time() - self._promote_timestamp > self.dcs.loop_wait:
|
|
||||||
self._process_quorum_replication()
|
|
||||||
if self._promote_timestamp == 0:
|
|
||||||
self._promote_timestamp = time.time()
|
|
||||||
elif self.is_synchronous_mode():
|
|
||||||
self._process_multisync_replication()
|
|
||||||
else:
|
|
||||||
self.disable_synchronous_replication()
|
|
||||||
|
|
||||||
def process_sync_replication_prepromote(self) -> bool:
|
|
||||||
"""Handle sync replication state before promote.
|
|
||||||
|
|
||||||
If quorum replication is requested, and we can keep syncing to enough nodes satisfying the quorum invariant
|
|
||||||
we can promote immediately and let normal quorum resolver process handle any membership changes later.
|
|
||||||
Otherwise, we will just reset DCS state to ourselves and add replicas as they connect.
|
|
||||||
|
|
||||||
:returns: ``True`` if on success or ``False`` if failed to update /sync key in DCS.
|
|
||||||
"""
|
|
||||||
if not self.is_synchronous_mode():
|
|
||||||
self.disable_synchronous_replication()
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.quorum_commit_mode_is_active():
|
|
||||||
sync = CaseInsensitiveSet(self.cluster.sync.members)
|
|
||||||
numsync = len(sync) - self.cluster.sync.quorum - 1
|
|
||||||
if self.state_handler.name not in sync: # Node outside voters achieved quorum and got leader
|
|
||||||
numsync += 1
|
|
||||||
else:
|
|
||||||
sync.discard(self.state_handler.name)
|
|
||||||
else:
|
|
||||||
sync = CaseInsensitiveSet()
|
|
||||||
numsync = global_config.min_synchronous_nodes
|
|
||||||
|
|
||||||
if not self.is_quorum_commit_mode() or not self.state_handler.supports_multiple_sync and numsync > 1:
|
|
||||||
sync = CaseInsensitiveSet()
|
|
||||||
numsync = global_config.min_synchronous_nodes
|
|
||||||
|
|
||||||
# Just set ourselves as the authoritative source of truth for now. We don't want to wait for standbys
|
|
||||||
# to connect. We will try finding a synchronous standby in the next cycle.
|
|
||||||
if not self.dcs.write_sync_state(self.state_handler.name, None, 0, version=self.cluster.sync.version):
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.state_handler.sync_handler.set_synchronous_standby_names(sync, numsync)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def is_sync_standby(self, cluster: Cluster) -> bool:
|
def is_sync_standby(self, cluster: Cluster) -> bool:
|
||||||
""":returns: `True` if the current node is a synchronous standby."""
|
""":returns: `True` if the current node is a synchronous standby."""
|
||||||
@@ -968,7 +805,7 @@ class Ha(object):
|
|||||||
cluster_history_dict: Dict[int, List[Any]] = {line[0]: list(line) for line in cluster_history}
|
cluster_history_dict: Dict[int, List[Any]] = {line[0]: list(line) for line in cluster_history}
|
||||||
history: List[List[Any]] = list(map(list, self.state_handler.get_history(primary_timeline)))
|
history: List[List[Any]] = list(map(list, self.state_handler.get_history(primary_timeline)))
|
||||||
if self.cluster.config:
|
if self.cluster.config:
|
||||||
history = history[-global_config.max_timelines_history:]
|
history = history[-self.cluster.config.max_timelines_history:]
|
||||||
for line in history:
|
for line in history:
|
||||||
# enrich current history with promotion timestamps stored in DCS
|
# enrich current history with promotion timestamps stored in DCS
|
||||||
cluster_history_line = cluster_history_dict.get(line[0], [])
|
cluster_history_line = cluster_history_dict.get(line[0], [])
|
||||||
@@ -1012,22 +849,27 @@ class Ha(object):
|
|||||||
self.state_handler.set_role('master')
|
self.state_handler.set_role('master')
|
||||||
self.process_sync_replication()
|
self.process_sync_replication()
|
||||||
self.update_cluster_history()
|
self.update_cluster_history()
|
||||||
self.state_handler.mpp_handler.sync_meta_data(self.cluster)
|
self.state_handler.citus_handler.sync_pg_dist_node(self.cluster)
|
||||||
return message
|
return message
|
||||||
elif self.state_handler.role in ('master', 'promoted', 'primary'):
|
elif self.state_handler.role in ('master', 'promoted', 'primary'):
|
||||||
self.process_sync_replication()
|
self.process_sync_replication()
|
||||||
return message
|
return message
|
||||||
else:
|
else:
|
||||||
if not self.process_sync_replication_prepromote():
|
if self.is_synchronous_mode():
|
||||||
# Somebody else updated sync state, it may be due to us losing the lock. To be safe,
|
# Just set ourselves as the authoritative source of truth for now. We don't want to wait for standbys
|
||||||
# postpone promotion until next cycle. TODO: trigger immediate retry of run_cycle.
|
# to connect. We will try finding a synchronous standby in the next cycle.
|
||||||
|
if not self.dcs.write_sync_state(self.state_handler.name, None, version=self.cluster.sync.version):
|
||||||
|
# Somebody else updated sync state, it may be due to us losing the lock. To be safe, postpone
|
||||||
|
# promotion until next cycle. TODO: trigger immediate retry of run_cycle
|
||||||
return 'Postponing promotion because synchronous replication state was updated by somebody else'
|
return 'Postponing promotion because synchronous replication state was updated by somebody else'
|
||||||
|
self.state_handler.sync_handler.set_synchronous_standby_names(
|
||||||
|
CaseInsensitiveSet('*') if self.global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
|
||||||
if self.state_handler.role not in ('master', 'promoted', 'primary'):
|
if self.state_handler.role not in ('master', 'promoted', 'primary'):
|
||||||
# reset failsafe state when promote
|
# reset failsafe state when promote
|
||||||
self._failsafe.set_is_active(0)
|
self._failsafe.set_is_active(0)
|
||||||
|
|
||||||
def before_promote():
|
def before_promote():
|
||||||
self.notify_mpp_coordinator('before_promote')
|
self.notify_citus_coordinator('before_promote')
|
||||||
|
|
||||||
with self._async_response:
|
with self._async_response:
|
||||||
self._async_response.reset()
|
self._async_response.reset()
|
||||||
@@ -1037,14 +879,10 @@ class Ha(object):
|
|||||||
return promote_message
|
return promote_message
|
||||||
|
|
||||||
def fetch_node_status(self, member: Member) -> _MemberStatus:
|
def fetch_node_status(self, member: Member) -> _MemberStatus:
|
||||||
"""Perform http get request on member.api_url to fetch its status.
|
"""This function perform http get request on member.api_url and fetches its status
|
||||||
|
:returns: `_MemberStatus` object
|
||||||
Usually this happens during the leader race and we can't afford to wait an indefinite time
|
|
||||||
for a response, therefore the request timeout is hardcoded to 2 seconds, which seems to be a
|
|
||||||
good compromise. The node which is slow to respond is most likely unhealthy.
|
|
||||||
|
|
||||||
:returns: :class:`_MemberStatus` object
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.patroni.request(member, timeout=2, retries=0)
|
response = self.patroni.request(member, timeout=2, retries=0)
|
||||||
data = response.data.decode('utf-8')
|
data = response.data.decode('utf-8')
|
||||||
@@ -1129,26 +967,18 @@ class Ha(object):
|
|||||||
return all(results)
|
return all(results)
|
||||||
|
|
||||||
def is_lagging(self, wal_position: int) -> bool:
|
def is_lagging(self, wal_position: int) -> bool:
|
||||||
"""Check if node should consider itself unhealthy to be promoted due to replication lag.
|
"""Returns if instance with an wal should consider itself unhealthy to be promoted due to replication lag.
|
||||||
|
|
||||||
:param wal_position: Current wal position.
|
:param wal_position: Current wal position.
|
||||||
|
|
||||||
:returns: ``True`` when node is lagging
|
:returns True when node is lagging
|
||||||
"""
|
"""
|
||||||
lag = (self.cluster.last_lsn or 0) - wal_position
|
lag = (self.cluster.last_lsn or 0) - wal_position
|
||||||
return lag > global_config.maximum_lag_on_failover
|
return lag > self.global_config.maximum_lag_on_failover
|
||||||
|
|
||||||
def _is_healthiest_node(self, members: Collection[Member], check_replication_lag: bool = True) -> bool:
|
def _is_healthiest_node(self, members: Collection[Member], check_replication_lag: bool = True) -> bool:
|
||||||
"""Determine whether the current node is healthy enough to become a new leader candidate.
|
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
|
||||||
|
|
||||||
:param members: the list of nodes to check against
|
|
||||||
:param check_replication_lag: whether to take the replication lag into account.
|
|
||||||
If the lag exceeds configured threshold the node disqualifies itself.
|
|
||||||
:returns: ``True`` if the node is eligible to become the new leader. Since this method is executed
|
|
||||||
on multiple nodes independently it is possible that multiple nodes could count
|
|
||||||
themselves as the healthiest because they received/replayed up to the same LSN,
|
|
||||||
but this is totally fine.
|
|
||||||
"""
|
|
||||||
my_wal_position = self.state_handler.last_operation()
|
my_wal_position = self.state_handler.last_operation()
|
||||||
if check_replication_lag and self.is_lagging(my_wal_position):
|
if check_replication_lag and self.is_lagging(my_wal_position):
|
||||||
logger.info('My wal position exceeds maximum replication lag')
|
logger.info('My wal position exceeds maximum replication lag')
|
||||||
@@ -1164,26 +994,8 @@ class Ha(object):
|
|||||||
logger.info('My timeline %s is behind last known cluster timeline %s', my_timeline, cluster_timeline)
|
logger.info('My timeline %s is behind last known cluster timeline %s', my_timeline, cluster_timeline)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.quorum_commit_mode_is_active():
|
# Prepare list of nodes to run check against
|
||||||
quorum = self.cluster.sync.quorum
|
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
|
||||||
voting_set = CaseInsensitiveSet(self.cluster.sync.members)
|
|
||||||
else:
|
|
||||||
quorum = 0
|
|
||||||
voting_set = CaseInsensitiveSet()
|
|
||||||
|
|
||||||
# Prepare list of nodes to run check against. If quorum commit is enabled
|
|
||||||
# we also include members with nofailover tag if they are listed in voters.
|
|
||||||
members = [m for m in members if m.name != self.state_handler.name
|
|
||||||
and m.api_url and (not m.nofailover or m.name in voting_set)]
|
|
||||||
|
|
||||||
# If there is a quorum active then at least one of the quorum contains latest commit. A quorum member saying
|
|
||||||
# their WAL position is not ahead counts as a vote saying we may become new leader. Note that a node doesn't
|
|
||||||
# have to be a member of the voting set to gather the necessary votes.
|
|
||||||
|
|
||||||
# Regardless of voting, if we observe a node that can become a leader and is ahead, we defer to that node.
|
|
||||||
# This can lead to failure to act on quorum if there is asymmetric connectivity.
|
|
||||||
quorum_votes = 0 if self.state_handler.name in voting_set else -1
|
|
||||||
nodes_ahead = 0
|
|
||||||
|
|
||||||
for st in self.fetch_nodes_statuses(members):
|
for st in self.fetch_nodes_statuses(members):
|
||||||
if st.failover_limitation() is None:
|
if st.failover_limitation() is None:
|
||||||
@@ -1191,34 +1003,22 @@ class Ha(object):
|
|||||||
logger.warning('Primary (%s) is still alive', st.member.name)
|
logger.warning('Primary (%s) is still alive', st.member.name)
|
||||||
return False
|
return False
|
||||||
if my_wal_position < st.wal_position:
|
if my_wal_position < st.wal_position:
|
||||||
nodes_ahead += 1
|
|
||||||
logger.info('Wal position of %s is ahead of my wal position', st.member.name)
|
logger.info('Wal position of %s is ahead of my wal position', st.member.name)
|
||||||
# In synchronous mode the former leader might be still accessible and even be ahead of us.
|
# In synchronous mode the former leader might be still accessible and even be ahead of us.
|
||||||
# We should not disqualify himself from the leader race in such a situation.
|
# We should not disqualify himself from the leader race in such a situation.
|
||||||
if not self.sync_mode_is_active() or not self.cluster.sync.leader_matches(st.member.name):
|
if not self.sync_mode_is_active() or not self.cluster.sync.leader_matches(st.member.name):
|
||||||
return False
|
return False
|
||||||
logger.info('Ignoring the former leader being ahead of us')
|
logger.info('Ignoring the former leader being ahead of us')
|
||||||
elif st.wal_position > 0: # we want to count votes only from nodes with postgres up and running!
|
if my_wal_position == st.wal_position and self.patroni.failover_priority < st.failover_priority:
|
||||||
quorum_vote = st.member.name in voting_set
|
|
||||||
low_priority = my_wal_position == st.wal_position \
|
|
||||||
and self.patroni.failover_priority < st.failover_priority
|
|
||||||
|
|
||||||
if low_priority and (not self.sync_mode_is_active() or quorum_vote):
|
|
||||||
# There's a higher priority non-lagging replica
|
# There's a higher priority non-lagging replica
|
||||||
logger.info(
|
logger.info(
|
||||||
'%s has equally tolerable WAL position and priority %s, while this node has priority %s',
|
'%s has equally tolerable WAL position and priority %s, while this node has priority %s',
|
||||||
st.member.name, st.failover_priority, self.patroni.failover_priority)
|
st.member.name,
|
||||||
|
st.failover_priority,
|
||||||
|
self.patroni.failover_priority,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
return True
|
||||||
if quorum_vote:
|
|
||||||
logger.info('Got quorum vote from %s', st.member.name)
|
|
||||||
quorum_votes += 1
|
|
||||||
|
|
||||||
# When not in quorum commit we just want to return `True`.
|
|
||||||
# In quorum commit the former leader is special and counted healthy even when there are no other nodes.
|
|
||||||
# Otherwise check that the number of votes exceeds the quorum field from the /sync key.
|
|
||||||
return not self.quorum_commit_mode_is_active() or quorum_votes >= quorum\
|
|
||||||
or nodes_ahead == 0 and self.cluster.sync.leader == self.state_handler.name
|
|
||||||
|
|
||||||
def is_failover_possible(self, *, cluster_lsn: int = 0, exclude_failover_candidate: bool = False) -> bool:
|
def is_failover_possible(self, *, cluster_lsn: int = 0, exclude_failover_candidate: bool = False) -> bool:
|
||||||
"""Checks whether any of the cluster members is allowed to promote and is healthy enough for that.
|
"""Checks whether any of the cluster members is allowed to promote and is healthy enough for that.
|
||||||
@@ -1278,10 +1078,9 @@ class Ha(object):
|
|||||||
return None
|
return None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# in synchronous mode (except quorum commit!) when our name is not in the
|
# in synchronous mode when our name is not in the /sync key
|
||||||
# /sync key we shouldn't take any action even if the candidate is unhealthy
|
# we shouldn't take any action even if the candidate is unhealthy
|
||||||
if self.is_synchronous_mode() and not self.is_quorum_commit_mode()\
|
if self.is_synchronous_mode() and not self.cluster.sync.matches(self.state_handler.name, True):
|
||||||
and not self.cluster.sync.matches(self.state_handler.name, True):
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# find specific node and check that it is healthy
|
# find specific node and check that it is healthy
|
||||||
@@ -1379,11 +1178,9 @@ class Ha(object):
|
|||||||
all_known_members += [RemoteMember(name, {'api_url': url}) for name, url in failsafe_members.items()]
|
all_known_members += [RemoteMember(name, {'api_url': url}) for name, url in failsafe_members.items()]
|
||||||
all_known_members += self.cluster.members
|
all_known_members += self.cluster.members
|
||||||
|
|
||||||
# Special handling if synchronous mode was requested and activated (the leader in /sync is not empty)
|
# When in sync mode, only last known primary and sync standby are allowed to promote automatically.
|
||||||
if self.sync_mode_is_active():
|
if self.sync_mode_is_active():
|
||||||
# In quorum commit mode we allow nodes outside of "voters" to take part in
|
if not self.cluster.sync.matches(self.state_handler.name, True):
|
||||||
# the leader race. They just need to get enough votes to `reach quorum + 1`.
|
|
||||||
if not self.is_quorum_commit_mode() and not self.cluster.sync.matches(self.state_handler.name, True):
|
|
||||||
return False
|
return False
|
||||||
# pick between synchronous candidates so we minimize unnecessary failovers/demotions
|
# pick between synchronous candidates so we minimize unnecessary failovers/demotions
|
||||||
members = {m.name: m for m in all_known_members if self.cluster.sync.matches(m.name, True)}
|
members = {m.name: m for m in all_known_members if self.cluster.sync.matches(m.name, True)}
|
||||||
@@ -1443,10 +1240,10 @@ class Ha(object):
|
|||||||
status['released'] = True
|
status['released'] = True
|
||||||
|
|
||||||
def before_shutdown() -> None:
|
def before_shutdown() -> None:
|
||||||
if self.state_handler.mpp_handler.is_coordinator():
|
if self.state_handler.citus_handler.is_coordinator():
|
||||||
self.state_handler.mpp_handler.on_demote()
|
self.state_handler.citus_handler.on_demote()
|
||||||
else:
|
else:
|
||||||
self.notify_mpp_coordinator('before_demote')
|
self.notify_citus_coordinator('before_demote')
|
||||||
|
|
||||||
self.state_handler.stop(str(mode_control['stop']), checkpoint=bool(mode_control['checkpoint']),
|
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,
|
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None,
|
||||||
@@ -1690,7 +1487,7 @@ class Ha(object):
|
|||||||
if postgres_version and postgres_version_to_int(postgres_version) <= int(self.state_handler.server_version):
|
if postgres_version and postgres_version_to_int(postgres_version) <= int(self.state_handler.server_version):
|
||||||
reason_to_cancel = "postgres version mismatch"
|
reason_to_cancel = "postgres version mismatch"
|
||||||
|
|
||||||
if pending_restart and not self.state_handler.pending_restart_reason:
|
if pending_restart and not self.state_handler.pending_restart:
|
||||||
reason_to_cancel = "pending restart flag is not set"
|
reason_to_cancel = "pending restart flag is not set"
|
||||||
|
|
||||||
if not reason_to_cancel:
|
if not reason_to_cancel:
|
||||||
@@ -1744,14 +1541,14 @@ class Ha(object):
|
|||||||
|
|
||||||
# Now that restart is scheduled we can set timeout for startup, it will get reset
|
# Now that restart is scheduled we can set timeout for startup, it will get reset
|
||||||
# once async executor runs and main loop notices PostgreSQL as up.
|
# once async executor runs and main loop notices PostgreSQL as up.
|
||||||
timeout = restart_data.get('timeout', global_config.primary_start_timeout)
|
timeout = restart_data.get('timeout', self.global_config.primary_start_timeout)
|
||||||
self.set_start_timeout(timeout)
|
self.set_start_timeout(timeout)
|
||||||
|
|
||||||
def before_shutdown() -> None:
|
def before_shutdown() -> None:
|
||||||
self.notify_mpp_coordinator('before_demote')
|
self.notify_citus_coordinator('before_demote')
|
||||||
|
|
||||||
def after_start() -> None:
|
def after_start() -> None:
|
||||||
self.notify_mpp_coordinator('after_promote')
|
self.notify_citus_coordinator('after_promote')
|
||||||
|
|
||||||
# For non async cases we want to wait for restart to complete or timeout before returning.
|
# 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,
|
do_restart = functools.partial(self.state_handler.restart, timeout, self._async_executor.critical_task,
|
||||||
@@ -1808,7 +1605,7 @@ class Ha(object):
|
|||||||
"""Figure out what to do with the task AsyncExecutor is performing."""
|
"""Figure out what to do with the task AsyncExecutor is performing."""
|
||||||
if self.has_lock() and self.update_lock():
|
if self.has_lock() and self.update_lock():
|
||||||
if self._async_executor.scheduled_action == 'doing crash recovery in a single user mode':
|
if self._async_executor.scheduled_action == 'doing crash recovery in a single user mode':
|
||||||
time_left = global_config.primary_start_timeout - (time.time() - self._crash_recovery_started)
|
time_left = self.global_config.primary_start_timeout - (time.time() - self._crash_recovery_started)
|
||||||
if time_left <= 0 and self.is_failover_possible():
|
if time_left <= 0 and self.is_failover_possible():
|
||||||
logger.info("Demoting self because crash recovery is taking too long")
|
logger.info("Demoting self because crash recovery is taking too long")
|
||||||
self.state_handler.cancellable.cancel(True)
|
self.state_handler.cancellable.cancel(True)
|
||||||
@@ -1893,7 +1690,7 @@ class Ha(object):
|
|||||||
self.set_is_leader(True)
|
self.set_is_leader(True)
|
||||||
if self.is_synchronous_mode():
|
if self.is_synchronous_mode():
|
||||||
self.state_handler.sync_handler.set_synchronous_standby_names(
|
self.state_handler.sync_handler.set_synchronous_standby_names(
|
||||||
CaseInsensitiveSet('*') if global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
|
CaseInsensitiveSet('*') if self.global_config.is_synchronous_mode_strict else CaseInsensitiveSet())
|
||||||
self.state_handler.call_nowait(CallbackAction.ON_START)
|
self.state_handler.call_nowait(CallbackAction.ON_START)
|
||||||
self.load_cluster_from_dcs()
|
self.load_cluster_from_dcs()
|
||||||
|
|
||||||
@@ -1916,7 +1713,7 @@ class Ha(object):
|
|||||||
self.demote('immediate-nolock')
|
self.demote('immediate-nolock')
|
||||||
return 'stopped PostgreSQL while starting up because leader key was lost'
|
return 'stopped PostgreSQL while starting up because leader key was lost'
|
||||||
|
|
||||||
timeout = self._start_timeout or global_config.primary_start_timeout
|
timeout = self._start_timeout or self.global_config.primary_start_timeout
|
||||||
time_left = timeout - self.state_handler.time_in_state()
|
time_left = timeout - self.state_handler.time_in_state()
|
||||||
|
|
||||||
if time_left <= 0:
|
if time_left <= 0:
|
||||||
@@ -1949,8 +1746,8 @@ class Ha(object):
|
|||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
self.load_cluster_from_dcs()
|
self.load_cluster_from_dcs()
|
||||||
global_config.update(self.cluster)
|
self.global_config = self.patroni.config.get_global_config(self.cluster)
|
||||||
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni)
|
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni.nofailover, self.global_config)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.state_handler.reset_cluster_info_state(None)
|
self.state_handler.reset_cluster_info_state(None)
|
||||||
raise
|
raise
|
||||||
@@ -1970,10 +1767,10 @@ class Ha(object):
|
|||||||
self.touch_member()
|
self.touch_member()
|
||||||
|
|
||||||
# cluster has leader key but not initialize key
|
# cluster has leader key but not initialize key
|
||||||
if self.has_lock(False) and not self.sysid_valid(self.cluster.initialize):
|
if not (self.cluster.is_unlocked() or self.sysid_valid(self.cluster.initialize)) and self.has_lock():
|
||||||
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
|
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
|
||||||
|
|
||||||
if self.has_lock(False) and not (self.cluster.config and self.cluster.config.data):
|
if not (self.cluster.is_unlocked() or self.cluster.config and self.cluster.config.data) and self.has_lock():
|
||||||
self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
|
self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
|
||||||
self.cluster = self.dcs.get_cluster()
|
self.cluster = self.dcs.get_cluster()
|
||||||
|
|
||||||
@@ -2106,7 +1903,7 @@ class Ha(object):
|
|||||||
if not is_promoting and create_slots and self.cluster.leader:
|
if not is_promoting and create_slots and self.cluster.leader:
|
||||||
err = self._async_executor.try_run_async('copy_logical_slots',
|
err = self._async_executor.try_run_async('copy_logical_slots',
|
||||||
self.state_handler.slots_handler.copy_logical_slots,
|
self.state_handler.slots_handler.copy_logical_slots,
|
||||||
args=(self.cluster, self.patroni, create_slots))
|
args=(self.cluster, create_slots))
|
||||||
if not err:
|
if not err:
|
||||||
ret = 'Copying logical slots {0} from the primary'.format(create_slots)
|
ret = 'Copying logical slots {0} from the primary'.format(create_slots)
|
||||||
return ret
|
return ret
|
||||||
@@ -2162,7 +1959,10 @@ class Ha(object):
|
|||||||
cluster = self._failsafe.update_cluster(self.cluster)\
|
cluster = self._failsafe.update_cluster(self.cluster)\
|
||||||
if self.is_failsafe_mode() and not self.is_leader() else self.cluster
|
if self.is_failsafe_mode() and not self.is_leader() else self.cluster
|
||||||
if cluster:
|
if cluster:
|
||||||
slots = self.state_handler.slots_handler.sync_replication_slots(cluster, self.patroni)
|
slots = self.state_handler.slots_handler.sync_replication_slots(cluster,
|
||||||
|
self.patroni.nofailover,
|
||||||
|
self.patroni.replicatefrom,
|
||||||
|
self.is_paused())
|
||||||
# Don't copy replication slots if failsafe_mode is active
|
# Don't copy replication slots if failsafe_mode is active
|
||||||
return [] if self.failsafe_is_active() else slots
|
return [] if self.failsafe_is_active() else slots
|
||||||
|
|
||||||
@@ -2204,7 +2004,7 @@ class Ha(object):
|
|||||||
self.dcs.write_leader_optime(prev_location)
|
self.dcs.write_leader_optime(prev_location)
|
||||||
|
|
||||||
def _before_shutdown() -> None:
|
def _before_shutdown() -> None:
|
||||||
self.notify_mpp_coordinator('before_demote')
|
self.notify_citus_coordinator('before_demote')
|
||||||
|
|
||||||
on_shutdown = _on_shutdown if self.is_leader() else None
|
on_shutdown = _on_shutdown if self.is_leader() else None
|
||||||
before_shutdown = _before_shutdown if self.is_leader() else None
|
before_shutdown = _before_shutdown if self.is_leader() else None
|
||||||
@@ -2246,7 +2046,7 @@ class Ha(object):
|
|||||||
config or cluster.config.data.
|
config or cluster.config.data.
|
||||||
"""
|
"""
|
||||||
data: Dict[str, Any] = {}
|
data: Dict[str, Any] = {}
|
||||||
cluster_params = global_config.get_standby_cluster_config()
|
cluster_params = self.global_config.get_standby_cluster_config()
|
||||||
|
|
||||||
if cluster_params:
|
if cluster_params:
|
||||||
data.update({k: v for k, v in cluster_params.items() if k in RemoteMember.ALLOWED_KEYS})
|
data.update({k: v for k, v in cluster_params.items() if k in RemoteMember.ALLOWED_KEYS})
|
||||||
@@ -2276,11 +2076,8 @@ class Ha(object):
|
|||||||
exclude = [self.state_handler.name] + ([failover.candidate] if failover and exclude_failover_candidate else [])
|
exclude = [self.state_handler.name] + ([failover.candidate] if failover and exclude_failover_candidate else [])
|
||||||
|
|
||||||
def is_eligible(node: Member) -> bool:
|
def is_eligible(node: Member) -> bool:
|
||||||
# If quorum commit is requested we want to check all nodes (even not voters),
|
|
||||||
# because they could get enough votes and reach necessary quorum + 1.
|
|
||||||
# in synchronous mode we allow failover (not switchover!) to async node
|
# in synchronous mode we allow failover (not switchover!) to async node
|
||||||
if self.sync_mode_is_active()\
|
if self.sync_mode_is_active() and not self.cluster.sync.matches(node.name)\
|
||||||
and not (self.is_quorum_commit_mode() or self.cluster.sync.matches(node.name))\
|
|
||||||
and not (failover and not failover.leader):
|
and not (failover and not failover.leader):
|
||||||
return False
|
return False
|
||||||
# Don't spend time on "nofailover" nodes checking.
|
# Don't spend time on "nofailover" nodes checking.
|
||||||
|
|||||||
+20
-166
@@ -9,15 +9,12 @@ import sys
|
|||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from patroni.utils import deep_compare
|
||||||
from queue import Queue, Full
|
from queue import Queue, Full
|
||||||
from threading import Lock, Thread
|
from threading import Lock, Thread
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +157,6 @@ class PatroniLogger(Thread):
|
|||||||
.. seealso::
|
.. seealso::
|
||||||
:class:`QueueHandler`: object used for enqueueing messages in-memory.
|
: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_LEVEL: default logging level (``INFO``).
|
||||||
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
|
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
|
||||||
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
|
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
|
||||||
@@ -173,7 +169,6 @@ class PatroniLogger(Thread):
|
|||||||
:ivar log_handler_lock: lock used to modify ``log_handler``.
|
:ivar log_handler_lock: lock used to modify ``log_handler``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEFAULT_TYPE = 'plain'
|
|
||||||
DEFAULT_LEVEL = 'INFO'
|
DEFAULT_LEVEL = 'INFO'
|
||||||
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
|
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
|
||||||
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
|
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
|
||||||
@@ -242,151 +237,6 @@ class PatroniLogger(Thread):
|
|||||||
logger = self._root_logger.manager.getLogger(name)
|
logger = self._root_logger.manager.getLogger(name)
|
||||||
logger.setLevel(level)
|
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:
|
def reload_config(self, config: Dict[str, Any]) -> None:
|
||||||
"""Apply log related configuration.
|
"""Apply log related configuration.
|
||||||
|
|
||||||
@@ -407,30 +257,34 @@ class PatroniLogger(Thread):
|
|||||||
# show stack traces as ``ERROR`` log messages
|
# show stack traces as ``ERROR`` log messages
|
||||||
logging.Logger.exception = error_exception
|
logging.Logger.exception = error_exception
|
||||||
|
|
||||||
handler = self.log_handler
|
new_handler = None
|
||||||
|
|
||||||
if 'dir' in config:
|
if 'dir' in config:
|
||||||
if not isinstance(handler, RotatingFileHandler):
|
if not isinstance(self.log_handler, RotatingFileHandler):
|
||||||
handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
|
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)
|
||||||
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
|
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
|
||||||
handler.backupCount = int(config.get('file_num', 4))
|
handler.backupCount = int(config.get('file_num', 4))
|
||||||
# we can't use `if not isinstance(handler, logging.StreamHandler)` below,
|
else:
|
||||||
# because RotatingFileHandler is a child of StreamHandler!!!
|
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
|
||||||
elif handler is None or isinstance(handler, RotatingFileHandler):
|
new_handler = logging.StreamHandler()
|
||||||
handler = logging.StreamHandler()
|
handler = new_handler or self.log_handler
|
||||||
|
|
||||||
is_new_handler = handler != self.log_handler
|
oldlogformat = (self._config or {}).get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||||
|
logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
|
||||||
|
|
||||||
if (self._is_config_changed(config) or is_new_handler) and handler:
|
olddateformat = (self._config or {}).get('dateformat') or None
|
||||||
formatter = self._get_formatter(config)
|
dateformat = config.get('dateformat') or None # Convert empty string to `None`
|
||||||
handler.setFormatter(formatter)
|
|
||||||
|
|
||||||
if is_new_handler:
|
if (oldlogformat != logformat or olddateformat != dateformat or new_handler) and handler:
|
||||||
|
handler.setFormatter(logging.Formatter(logformat, dateformat))
|
||||||
|
|
||||||
|
if new_handler:
|
||||||
with self.log_handler_lock:
|
with self.log_handler_lock:
|
||||||
if self.log_handler:
|
if self.log_handler:
|
||||||
self._old_handlers.append(self.log_handler)
|
self._old_handlers.append(self.log_handler)
|
||||||
self.log_handler = handler
|
self.log_handler = new_handler
|
||||||
|
|
||||||
self._config = config.copy()
|
self._config = config.copy()
|
||||||
self.update_loggers(config.get('loggers') or {})
|
self.update_loggers(config.get('loggers') or {})
|
||||||
|
|||||||
@@ -19,22 +19,22 @@ from .callback_executor import CallbackAction, CallbackExecutor
|
|||||||
from .cancellable import CancellableSubprocess
|
from .cancellable import CancellableSubprocess
|
||||||
from .config import ConfigHandler, mtime
|
from .config import ConfigHandler, mtime
|
||||||
from .connection import ConnectionPool, get_connection_cursor
|
from .connection import ConnectionPool, get_connection_cursor
|
||||||
|
from .citus import CitusHandler
|
||||||
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
|
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
|
||||||
from .mpp import AbstractMPP
|
|
||||||
from .postmaster import PostmasterProcess
|
from .postmaster import PostmasterProcess
|
||||||
from .slots import SlotsHandler
|
from .slots import SlotsHandler
|
||||||
from .sync import SyncHandler
|
from .sync import SyncHandler
|
||||||
from .. import global_config, psycopg
|
from .. import psycopg
|
||||||
from ..async_executor import CriticalTask
|
from ..async_executor import CriticalTask
|
||||||
from ..collections import CaseInsensitiveSet, CaseInsensitiveDict
|
from ..collections import CaseInsensitiveSet
|
||||||
from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION
|
from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION
|
||||||
from ..exceptions import PostgresConnectionException
|
from ..exceptions import PostgresConnectionException
|
||||||
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
|
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
|
||||||
from ..tags import Tags
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from psycopg import Connection as Connection3, Cursor
|
from psycopg import Connection as Connection3, Cursor
|
||||||
from psycopg2 import connection as connection3, cursor
|
from psycopg2 import connection as connection3, cursor
|
||||||
|
from ..config import GlobalConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -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_{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()")
|
"pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused()")
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
self.name: str = config['name']
|
self.name: str = config['name']
|
||||||
self.scope: str = config['scope']
|
self.scope: str = config['scope']
|
||||||
self._data_dir: str = config['data_dir']
|
self._data_dir: str = config['data_dir']
|
||||||
@@ -73,14 +73,15 @@ class Postgresql(object):
|
|||||||
self.connection_string: str
|
self.connection_string: str
|
||||||
self.proxy_url: Optional[str]
|
self.proxy_url: Optional[str]
|
||||||
self._major_version = self.get_major_version()
|
self._major_version = self.get_major_version()
|
||||||
|
self._global_config = None
|
||||||
|
|
||||||
self._state_lock = Lock()
|
self._state_lock = Lock()
|
||||||
self.set_state('stopped')
|
self.set_state('stopped')
|
||||||
|
|
||||||
self._pending_restart_reason = CaseInsensitiveDict()
|
self._pending_restart = False
|
||||||
self.connection_pool = ConnectionPool()
|
self.connection_pool = ConnectionPool()
|
||||||
self._connection = self.connection_pool.get('heartbeat')
|
self._connection = self.connection_pool.get('heartbeat')
|
||||||
self.mpp_handler = mpp.get_handler_impl(self)
|
self.citus_handler = CitusHandler(self, config.get('citus'))
|
||||||
self.config = ConfigHandler(self, config)
|
self.config = ConfigHandler(self, config)
|
||||||
self.config.check_directories()
|
self.config.check_directories()
|
||||||
|
|
||||||
@@ -181,11 +182,6 @@ class Postgresql(object):
|
|||||||
def lsn_name(self) -> str:
|
def lsn_name(self) -> str:
|
||||||
return 'lsn' if self._major_version >= 100000 else 'location'
|
return 'lsn' if self._major_version >= 100000 else 'location'
|
||||||
|
|
||||||
@property
|
|
||||||
def supports_quorum_commit(self) -> bool:
|
|
||||||
"""``True`` if quorum commit is supported by Postgres."""
|
|
||||||
return self._major_version >= 100000
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_multiple_sync(self) -> bool:
|
def supports_multiple_sync(self) -> bool:
|
||||||
""":returns: `True` if Postgres version supports more than one synchronous node."""
|
""":returns: `True` if Postgres version supports more than one synchronous node."""
|
||||||
@@ -223,7 +219,7 @@ class Postgresql(object):
|
|||||||
"FROM pg_catalog.pg_stat_get_wal_senders() w,"
|
"FROM pg_catalog.pg_stat_get_wal_senders() w,"
|
||||||
" pg_catalog.pg_stat_get_activity(w.pid)"
|
" pg_catalog.pg_stat_get_activity(w.pid)"
|
||||||
" WHERE w.state = 'streaming') r)").format(self.wal_name, self.lsn_name)
|
" WHERE w.state = 'streaming') r)").format(self.wal_name, self.lsn_name)
|
||||||
if global_config.is_synchronous_mode
|
if (not self.global_config or self.global_config.is_synchronous_mode)
|
||||||
and self.role in ('master', 'primary', 'promoted') else "'on', '', NULL")
|
and self.role in ('master', 'primary', 'promoted') else "'on', '', NULL")
|
||||||
|
|
||||||
if self._major_version >= 90600:
|
if self._major_version >= 90600:
|
||||||
@@ -326,22 +322,11 @@ class Postgresql(object):
|
|||||||
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout'] / 2.0
|
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout'] / 2.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_restart_reason(self) -> CaseInsensitiveDict:
|
def pending_restart(self) -> bool:
|
||||||
"""Get :attr:`_pending_restart_reason` value.
|
return self._pending_restart
|
||||||
|
|
||||||
:attr:`_pending_restart_reason` is a :class:`CaseInsensitiveDict` object of the PG parameters that are
|
def set_pending_restart(self, value: bool) -> None:
|
||||||
causing pending restart state. Every key is a parameter name, value - a dictionary containing the old
|
self._pending_restart = value
|
||||||
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
|
@property
|
||||||
def sysid(self) -> str:
|
def sysid(self) -> str:
|
||||||
@@ -445,30 +430,46 @@ class Postgresql(object):
|
|||||||
self.config.write_postgresql_conf()
|
self.config.write_postgresql_conf()
|
||||||
self.reload()
|
self.reload()
|
||||||
|
|
||||||
def reset_cluster_info_state(self, cluster: Optional[Cluster], tags: Optional[Tags] = None) -> None:
|
@property
|
||||||
|
def global_config(self) -> Optional['GlobalConfig']:
|
||||||
|
return self._global_config
|
||||||
|
|
||||||
|
def reset_cluster_info_state(self, cluster: Union[Cluster, None], nofailover: bool = False,
|
||||||
|
global_config: Optional['GlobalConfig'] = None) -> None:
|
||||||
"""Reset monitoring query cache.
|
"""Reset monitoring query cache.
|
||||||
|
|
||||||
.. note::
|
|
||||||
It happens in the beginning of heart-beat loop and on change of `synchronous_standby_names`.
|
It happens in the beginning of heart-beat loop and on change of `synchronous_standby_names`.
|
||||||
|
|
||||||
:param cluster: currently known cluster state from DCS
|
:param cluster: currently known cluster state from DCS
|
||||||
:param tags: reference to an object implementing :class:`Tags` interface.
|
:param nofailover: whether this node could become a new primary.
|
||||||
|
Important when there are logical permanent replication slots because "nofailover"
|
||||||
|
node could do cascading replication and should enable `hot_standby_feedback`
|
||||||
|
:param global_config: last known :class:`GlobalConfig` object
|
||||||
"""
|
"""
|
||||||
self._cluster_info_state = {}
|
self._cluster_info_state = {}
|
||||||
|
|
||||||
if not tags:
|
if global_config:
|
||||||
|
self._global_config = global_config
|
||||||
|
|
||||||
|
if not self._global_config:
|
||||||
return
|
return
|
||||||
|
|
||||||
if global_config.is_standby_cluster:
|
if self._global_config.is_standby_cluster:
|
||||||
# Standby cluster can't have logical replication slots, and we don't need to enforce hot_standby_feedback
|
# Standby cluster can't have logical replication slots, and we don't need to enforce hot_standby_feedback
|
||||||
self.set_enforce_hot_standby_feedback(False)
|
self.set_enforce_hot_standby_feedback(False)
|
||||||
|
|
||||||
if cluster and cluster.config and cluster.config.modify_version:
|
if cluster and cluster.config and cluster.config.modify_version:
|
||||||
# We want to enable hot_standby_feedback if the replica is supposed
|
# We want to enable hot_standby_feedback if the replica is supposed
|
||||||
# to have a logical slot or in case if it is the cascading replica.
|
# to have a logical slot or in case if it is the cascading replica.
|
||||||
self.set_enforce_hot_standby_feedback(not global_config.is_standby_cluster and self.can_advance_slots
|
self.set_enforce_hot_standby_feedback(not self._global_config.is_standby_cluster and self.can_advance_slots
|
||||||
and cluster.should_enforce_hot_standby_feedback(self, tags))
|
and cluster.should_enforce_hot_standby_feedback(self.name,
|
||||||
self._has_permanent_slots = cluster.has_permanent_slots(self, tags)
|
nofailover))
|
||||||
|
|
||||||
|
self._has_permanent_slots = cluster.has_permanent_slots(
|
||||||
|
my_name=self.name,
|
||||||
|
is_standby_cluster=self._global_config.is_standby_cluster,
|
||||||
|
nofailover=nofailover,
|
||||||
|
major_version=self.major_version)
|
||||||
|
|
||||||
def _cluster_info_state_get(self, name: str) -> Optional[Any]:
|
def _cluster_info_state_get(self, name: str) -> Optional[Any]:
|
||||||
if not self._cluster_info_state:
|
if not self._cluster_info_state:
|
||||||
@@ -743,7 +744,7 @@ class Postgresql(object):
|
|||||||
self.set_role(role or self.get_postgres_role_from_data_directory())
|
self.set_role(role or self.get_postgres_role_from_data_directory())
|
||||||
|
|
||||||
self.set_state('starting')
|
self.set_state('starting')
|
||||||
self.set_pending_restart_reason(CaseInsensitiveDict())
|
self._pending_restart = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.ensure_major_version_is_known():
|
if not self.ensure_major_version_is_known():
|
||||||
@@ -1213,7 +1214,7 @@ class Postgresql(object):
|
|||||||
before_promote()
|
before_promote()
|
||||||
|
|
||||||
self.slots_handler.on_promote()
|
self.slots_handler.on_promote()
|
||||||
self.mpp_handler.schedule_cache_rebuild()
|
self.citus_handler.schedule_cache_rebuild()
|
||||||
|
|
||||||
ret = self.pg_ctl('promote', '-W')
|
ret = self.pg_ctl('promote', '-W')
|
||||||
if ret:
|
if ret:
|
||||||
@@ -1360,7 +1361,7 @@ class Postgresql(object):
|
|||||||
"""
|
"""
|
||||||
self.ensure_major_version_is_known()
|
self.ensure_major_version_is_known()
|
||||||
self.slots_handler.schedule()
|
self.slots_handler.schedule()
|
||||||
self.mpp_handler.schedule_cache_rebuild()
|
self.citus_handler.schedule_cache_rebuild()
|
||||||
self._sysid = ''
|
self._sysid = ''
|
||||||
|
|
||||||
def _get_gucs(self) -> CaseInsensitiveSet:
|
def _get_gucs(self) -> CaseInsensitiveSet:
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import logging
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
if sys.version_info < (3, 9):
|
|
||||||
PathLikeObj = Path
|
|
||||||
conf_dir = Path(__file__).parent
|
|
||||||
else:
|
|
||||||
from importlib.resources import files
|
|
||||||
|
|
||||||
if sys.version_info < (3, 11): # pragma: no cover
|
|
||||||
from importlib.abc import Traversable
|
|
||||||
else: # pragma: no cover
|
|
||||||
from importlib.resources.abc import Traversable
|
|
||||||
|
|
||||||
PathLikeObj = Traversable
|
|
||||||
conf_dir = files(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_validator_files() -> Iterator[PathLikeObj]:
|
|
||||||
"""Recursively find YAML files from the current package directory.
|
|
||||||
|
|
||||||
:returns: an iterator of :class:`PathLikeObj` objects representing validator files.
|
|
||||||
"""
|
|
||||||
return _traversable_walk(conf_dir.iterdir())
|
|
||||||
|
|
||||||
|
|
||||||
def _traversable_walk(tvbs: Iterator[PathLikeObj]) -> Iterator[PathLikeObj]:
|
|
||||||
"""Recursively walk through Path/Traversable objects, yielding all YAML files in deterministic order.
|
|
||||||
|
|
||||||
:param tvbs: An iterator over :class:`PathLikeObj` objects, where each object is a file or directory
|
|
||||||
that potentially contains YAML files.
|
|
||||||
|
|
||||||
:yields: :class:`PathLikeObj` objects representing YAML files found during the traversal.
|
|
||||||
"""
|
|
||||||
for tvb in _filter_and_sort_files(tvbs):
|
|
||||||
if tvb.is_file():
|
|
||||||
yield tvb
|
|
||||||
elif tvb.is_dir():
|
|
||||||
yield from _traversable_walk(tvb.iterdir())
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_and_sort_files(files: Iterator[PathLikeObj]) -> Iterator[PathLikeObj]:
|
|
||||||
"""Sort files by name, and filter out non-YAML files and Python files.
|
|
||||||
|
|
||||||
:param files: A list of files and/or directories to be filtered and sorted.
|
|
||||||
|
|
||||||
:yields: filtered and sorted objects.
|
|
||||||
"""
|
|
||||||
for file in sorted(files, key=lambda x: x.name):
|
|
||||||
if file.name.lower().endswith((".yml", ".yaml")) or file.is_dir():
|
|
||||||
yield file
|
|
||||||
elif not file.name.lower().endswith((".py", ".pyc")):
|
|
||||||
logger.info("Ignored a non-YAML file found under `%s` directory: `%s`.", __name__.split('.')[-1], file)
|
|
||||||
@@ -100,11 +100,10 @@ class Bootstrap(object):
|
|||||||
user_options.append('--{0}'.format(opt))
|
user_options.append('--{0}'.format(opt))
|
||||||
elif isinstance(opt, dict):
|
elif isinstance(opt, dict):
|
||||||
keys = list(opt.keys())
|
keys = list(opt.keys())
|
||||||
if len(keys) == 1 and isinstance(opt[keys[0]], str) and option_is_allowed(keys[0]):
|
if len(keys) != 1 or not isinstance(opt[keys[0]], str) or not 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'
|
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]]))
|
' and value should be a string'.format(tool, opt[keys[0]]))
|
||||||
|
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
|
||||||
else:
|
else:
|
||||||
error_handler('Error when parsing {0} option {1}: value should be string value'
|
error_handler('Error when parsing {0} option {1}: value should be string value'
|
||||||
' or a single key-value pair'.format(tool, opt))
|
' or a single key-value pair'.format(tool, opt))
|
||||||
@@ -464,15 +463,15 @@ END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
|
|||||||
postgresql.restart()
|
postgresql.restart()
|
||||||
else:
|
else:
|
||||||
postgresql.config.replace_pg_hba()
|
postgresql.config.replace_pg_hba()
|
||||||
if postgresql.pending_restart_reason:
|
if postgresql.pending_restart:
|
||||||
postgresql.restart()
|
postgresql.restart()
|
||||||
else:
|
else:
|
||||||
postgresql.reload()
|
postgresql.reload()
|
||||||
time.sleep(1) # give a time to postgres to "reload" configuration files
|
time.sleep(1) # give a time to postgres to "reload" configuration files
|
||||||
postgresql.connection().close() # close connection to reconnect with a new password
|
postgresql.connection().close() # close connection to reconnect with a new password
|
||||||
else: # initdb
|
else: # initdb
|
||||||
# We may want create database and extension for some MPP clusters
|
# We may want create database and extension for citus
|
||||||
self._postgresql.mpp_handler.bootstrap()
|
self._postgresql.citus_handler.bootstrap()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('post_bootstrap')
|
logger.exception('post_bootstrap')
|
||||||
task.complete(False)
|
task.complete(False)
|
||||||
|
|||||||
@@ -6,15 +6,12 @@ from threading import Condition, Event, Thread
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
from . import AbstractMPP, AbstractMPPHandler
|
from ..dcs import CITUS_COORDINATOR_GROUP_ID, Cluster
|
||||||
from ...dcs import Cluster
|
from ..psycopg import connect, quote_ident, ProgrammingError
|
||||||
from ...psycopg import connect, quote_ident, ProgrammingError
|
|
||||||
from ...utils import parse_int
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
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}$')
|
CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -66,45 +63,13 @@ class PgDistNode(object):
|
|||||||
return str(self)
|
return str(self)
|
||||||
|
|
||||||
|
|
||||||
class Citus(AbstractMPP):
|
class CitusHandler(Thread):
|
||||||
|
|
||||||
group_re = re.compile('^(0|[1-9][0-9]*)$')
|
def __init__(self, postgresql: 'Postgresql', config: Optional[Dict[str, Union[str, int]]]) -> None:
|
||||||
|
super(CitusHandler, self).__init__()
|
||||||
@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.daemon = True
|
||||||
|
self._postgresql = postgresql
|
||||||
|
self._config = config
|
||||||
if config:
|
if config:
|
||||||
self._connection = postgresql.connection_pool.get(
|
self._connection = postgresql.connection_pool.get(
|
||||||
'citus', {'dbname': config['database'],
|
'citus', {'dbname': config['database'],
|
||||||
@@ -116,11 +81,19 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
|
|||||||
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node
|
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node
|
||||||
self.schedule_cache_rebuild()
|
self.schedule_cache_rebuild()
|
||||||
|
|
||||||
def schedule_cache_rebuild(self) -> None:
|
def is_enabled(self) -> bool:
|
||||||
"""Cache rebuild handler.
|
return isinstance(self._config, dict)
|
||||||
|
|
||||||
Is called to notify handler that it has to refresh its metadata cache from the database.
|
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:
|
||||||
with self._condition:
|
with self._condition:
|
||||||
self._schedule_load_pg_dist_node = True
|
self._schedule_load_pg_dist_node = True
|
||||||
|
|
||||||
@@ -161,8 +134,8 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
|
|||||||
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in rows}
|
self._pg_dist_node = {r[1]: PgDistNode(r[1], r[2], r[3], 'after_promote', r[0]) for r in rows}
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def sync_meta_data(self, cluster: Cluster) -> None:
|
def sync_pg_dist_node(self, cluster: Cluster) -> None:
|
||||||
"""Maintain the ``pg_dist_node`` from the coordinator leader every heartbeat loop.
|
"""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
|
We can't always rely on REST API calls from worker nodes in order
|
||||||
to maintain `pg_dist_node`, therefore at least once per heartbeat
|
to maintain `pg_dist_node`, therefore at least once per heartbeat
|
||||||
@@ -323,16 +296,16 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
|
|||||||
with self._condition:
|
with self._condition:
|
||||||
i = self.find_task_by_group(task.group)
|
i = self.find_task_by_group(task.group)
|
||||||
|
|
||||||
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_meta_data().
|
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_pg_dist_node().
|
||||||
if task.timeout is None:
|
if task.timeout is None:
|
||||||
# We don't want to override the already existing task created from REST API.
|
# 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:
|
if i is not None and self._tasks[i].timeout is not None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# There is a little race condition with tasks created from REST API - the call made "before" the member
|
# 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_meta_data` will try to create a task
|
# key is updated in DCS. Therefore it is possible that :func:`sync_pg_dist_node` will try to create a
|
||||||
# based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
|
# 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()
|
# Only when the timeout is reached new tasks could be scheduled from sync_pg_dist_node()
|
||||||
if self._in_flight and self._in_flight.group == task.group and self._in_flight.timeout is not None\
|
if self._in_flight and self._in_flight.group == task.group and self._in_flight.timeout is not None\
|
||||||
and self._in_flight.deadline > time.time():
|
and self._in_flight.deadline > time.time():
|
||||||
return False
|
return False
|
||||||
@@ -380,10 +353,9 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
|
|||||||
task.wait()
|
task.wait()
|
||||||
|
|
||||||
def bootstrap(self) -> None:
|
def bootstrap(self) -> None:
|
||||||
"""Bootstrap handler.
|
if not isinstance(self._config, dict): # self.is_enabled()
|
||||||
|
return
|
||||||
|
|
||||||
Is called when the new cluster is initialized (through ``initdb`` or a custom bootstrap method).
|
|
||||||
"""
|
|
||||||
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs,
|
conn_kwargs = {**self._postgresql.connection_pool.conn_kwargs,
|
||||||
'options': '-c synchronous_commit=local -c statement_timeout=0'}
|
'options': '-c synchronous_commit=local -c statement_timeout=0'}
|
||||||
if self._config['database'] != self._postgresql.database:
|
if self._config['database'] != self._postgresql.database:
|
||||||
@@ -421,10 +393,9 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
|
def adjust_postgres_gucs(self, parameters: Dict[str, Any]) -> None:
|
||||||
"""Adjust GUCs in the current PostgreSQL configuration.
|
if not self.is_enabled():
|
||||||
|
return
|
||||||
|
|
||||||
: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
|
# citus extension must be on the first place in shared_preload_libraries
|
||||||
shared_preload_libraries = list(filter(
|
shared_preload_libraries = list(filter(
|
||||||
lambda el: el and el != 'citus',
|
lambda el: el and el != 'citus',
|
||||||
@@ -442,18 +413,8 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
|
|||||||
parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
|
parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
|
||||||
|
|
||||||
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
|
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
|
||||||
"""Check whether provided replication *slot* existing in the database should not be removed.
|
if isinstance(self._config, dict) and self._postgresql.is_primary() and\
|
||||||
|
slot['type'] == 'logical' and slot['database'] == self._config['database']:
|
||||||
.. 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'])
|
m = CITUS_SLOT_NAME_RE.match(slot['name'])
|
||||||
return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
|
return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
|
||||||
return False
|
return False
|
||||||
@@ -9,16 +9,14 @@ import time
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from urllib.parse import urlparse, parse_qsl, unquote
|
from urllib.parse import urlparse, parse_qsl, unquote
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
from typing import Any, Collection, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||||
|
|
||||||
from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
|
from .validator import recovery_parameters, transform_postgresql_parameter_value, transform_recovery_parameter_value
|
||||||
from .. import global_config
|
|
||||||
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||||
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
|
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
|
||||||
from ..exceptions import PatroniFatalException, PostgresConnectionException
|
from ..exceptions import PatroniFatalException, PostgresConnectionException
|
||||||
from ..file_perm import pg_perm
|
from ..file_perm import pg_perm
|
||||||
from ..utils import (compare_values, maybe_convert_from_base_unit, parse_bool, parse_int,
|
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
|
||||||
split_host_port, uri, validate_directory, is_subpath)
|
|
||||||
from ..validator import IntValidator, EnumValidator
|
from ..validator import IntValidator, EnumValidator
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
@@ -271,29 +269,6 @@ def _bool_is_true_validator(value: Any) -> bool:
|
|||||||
return parse_bool(value) is True
|
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):
|
class ConfigHandler(object):
|
||||||
|
|
||||||
# List of parameters which must be always passed to postmaster as command line options
|
# List of parameters which must be always passed to postmaster as command line options
|
||||||
@@ -632,14 +607,15 @@ class ConfigHandler(object):
|
|||||||
is_remote_member = isinstance(member, RemoteMember)
|
is_remote_member = isinstance(member, RemoteMember)
|
||||||
primary_conninfo = self.primary_conninfo_params(member)
|
primary_conninfo = self.primary_conninfo_params(member)
|
||||||
if primary_conninfo:
|
if primary_conninfo:
|
||||||
use_slots = global_config.use_slots and self._postgresql.major_version >= 90400
|
use_slots = self.get('use_slots', True) and self._postgresql.major_version >= 90400
|
||||||
if use_slots and not (is_remote_member and member.no_replication_slot):
|
if use_slots and not (is_remote_member and member.no_replication_slot):
|
||||||
primary_slot_name = member.primary_slot_name if is_remote_member else self._postgresql.name
|
primary_slot_name = member.primary_slot_name if is_remote_member else self._postgresql.name
|
||||||
recovery_params['primary_slot_name'] = slot_name_from_member_name(primary_slot_name)
|
recovery_params['primary_slot_name'] = slot_name_from_member_name(primary_slot_name)
|
||||||
# We are a standby leader and are using a replication slot. Make sure we connect to
|
# We are a standby leader and are using a replication slot. Make sure we connect to
|
||||||
# the leader of the main cluster (in case more than one host is specified in the
|
# the leader of the main cluster (in case more than one host is specified in the
|
||||||
# connstr) by adding 'target_session_attrs=read-write' to primary_conninfo.
|
# connstr) by adding 'target_session_attrs=read-write' to primary_conninfo.
|
||||||
if is_remote_member and ',' in primary_conninfo['host'] and self._postgresql.major_version >= 100000:
|
if is_remote_member and 'target_sesions_attrs' not in primary_conninfo and\
|
||||||
|
self._postgresql.major_version >= 100000:
|
||||||
primary_conninfo['target_session_attrs'] = 'read-write'
|
primary_conninfo['target_session_attrs'] = 'read-write'
|
||||||
recovery_params['primary_conninfo'] = primary_conninfo
|
recovery_params['primary_conninfo'] = primary_conninfo
|
||||||
|
|
||||||
@@ -966,10 +942,10 @@ class ConfigHandler(object):
|
|||||||
parameters = config['parameters'].copy()
|
parameters = config['parameters'].copy()
|
||||||
listen_addresses, port = split_host_port(config['listen'], 5432)
|
listen_addresses, port = split_host_port(config['listen'], 5432)
|
||||||
parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port))
|
parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port))
|
||||||
if global_config.is_synchronous_mode:
|
if not self._postgresql.global_config or self._postgresql.global_config.is_synchronous_mode:
|
||||||
synchronous_standby_names = self._server_parameters.get('synchronous_standby_names')
|
synchronous_standby_names = self._server_parameters.get('synchronous_standby_names')
|
||||||
if synchronous_standby_names is None:
|
if synchronous_standby_names is None:
|
||||||
if global_config.is_synchronous_mode_strict\
|
if self._postgresql.global_config and self._postgresql.global_config.is_synchronous_mode_strict\
|
||||||
and self._postgresql.role in ('master', 'primary', 'promoted'):
|
and self._postgresql.role in ('master', 'primary', 'promoted'):
|
||||||
parameters['synchronous_standby_names'] = '*'
|
parameters['synchronous_standby_names'] = '*'
|
||||||
else:
|
else:
|
||||||
@@ -991,7 +967,7 @@ class ConfigHandler(object):
|
|||||||
wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB')
|
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))
|
parameters.setdefault('wal_keep_segments', int(((wal_keep_size or 0) + 8) / 16))
|
||||||
|
|
||||||
self._postgresql.mpp_handler.adjust_postgres_gucs(parameters)
|
self._postgresql.citus_handler.adjust_postgres_gucs(parameters)
|
||||||
|
|
||||||
ret = CaseInsensitiveDict({k: v for k, v in parameters.items() if not self._postgresql.major_version
|
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]})
|
or self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
|
||||||
@@ -1102,8 +1078,7 @@ class ConfigHandler(object):
|
|||||||
server_parameters = self.get_server_parameters(config)
|
server_parameters = self.get_server_parameters(config)
|
||||||
params_skip_changes = CaseInsensitiveSet((*self._RECOVERY_PARAMETERS, 'hot_standby', 'wal_log_hints'))
|
params_skip_changes = CaseInsensitiveSet((*self._RECOVERY_PARAMETERS, 'hot_standby', 'wal_log_hints'))
|
||||||
|
|
||||||
conf_changed = hba_changed = ident_changed = local_connection_address_changed = False
|
conf_changed = hba_changed = ident_changed = local_connection_address_changed = pending_restart = False
|
||||||
param_diff = CaseInsensitiveDict()
|
|
||||||
if self._postgresql.state == 'running':
|
if self._postgresql.state == 'running':
|
||||||
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items()
|
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items()
|
||||||
if p not in params_skip_changes})
|
if p not in params_skip_changes})
|
||||||
@@ -1127,28 +1102,26 @@ class ConfigHandler(object):
|
|||||||
if new_value is None or not compare_values(r[3], r[2], r[1], new_value):
|
if new_value is None or not compare_values(r[3], r[2], r[1], new_value):
|
||||||
conf_changed = True
|
conf_changed = True
|
||||||
if r[4] == 'postmaster':
|
if r[4] == 'postmaster':
|
||||||
param_diff[r[0]] = get_param_diff(r[1], new_value, r[3], r[2])
|
pending_restart = True
|
||||||
logger.info("Changed %s from '%s' to '%s' (restart might be required)",
|
logger.info('Changed %s from %s to %s (restart might be required)',
|
||||||
r[0], param_diff[r[0]]['old_value'], new_value)
|
r[0], r[1], new_value)
|
||||||
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
|
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
|
||||||
or r[0] in ('listen_addresses', 'port'):
|
or r[0] in ('listen_addresses', 'port'):
|
||||||
local_connection_address_changed = True
|
local_connection_address_changed = True
|
||||||
else:
|
else:
|
||||||
logger.info("Changed %s from '%s' to '%s'",
|
logger.info('Changed %s from %s to %s', r[0], r[1], new_value)
|
||||||
r[0], maybe_convert_from_base_unit(r[1], r[3], r[2]), new_value)
|
|
||||||
elif r[0] in self._server_parameters \
|
elif r[0] in self._server_parameters \
|
||||||
and not compare_values(r[3], r[2], r[1], self._server_parameters[r[0]]):
|
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
|
# 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
|
# 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]], new_value)
|
logger.info('Changed %s from %s to %s', r[0], self._server_parameters[r[0]], r[1])
|
||||||
conf_changed = True
|
conf_changed = True
|
||||||
for param, value in changes.items():
|
for param, value in changes.items():
|
||||||
if '.' in param:
|
if '.' in param:
|
||||||
# Check that user-defined-parameters have changed (parameters with period in name)
|
# Check that user-defined-paramters have changed (parameters with period in name)
|
||||||
if value is None or param not in self._server_parameters \
|
if value is None or param not in self._server_parameters \
|
||||||
or str(value) != str(self._server_parameters[param]):
|
or str(value) != str(self._server_parameters[param]):
|
||||||
logger.info("Changed %s from '%s' to '%s'",
|
logger.info('Changed %s from %s to %s', param, self._server_parameters.get(param), value)
|
||||||
param, self._server_parameters.get(param), value)
|
|
||||||
conf_changed = True
|
conf_changed = True
|
||||||
elif param in server_parameters:
|
elif param in server_parameters:
|
||||||
logger.warning('Removing invalid parameter `%s` from postgresql.parameters', param)
|
logger.warning('Removing invalid parameter `%s` from postgresql.parameters', param)
|
||||||
@@ -1163,6 +1136,7 @@ class ConfigHandler(object):
|
|||||||
ident_changed = self._config.get('pg_ident', []) != config['pg_ident']
|
ident_changed = self._config.get('pg_ident', []) != config['pg_ident']
|
||||||
|
|
||||||
self._config = config
|
self._config = config
|
||||||
|
self._postgresql.set_pending_restart(pending_restart)
|
||||||
self._server_parameters = server_parameters
|
self._server_parameters = server_parameters
|
||||||
self._adjust_recovery_parameters()
|
self._adjust_recovery_parameters()
|
||||||
self._krbsrvname = config.get('krbsrvname')
|
self._krbsrvname = config.get('krbsrvname')
|
||||||
@@ -1192,28 +1166,16 @@ class ConfigHandler(object):
|
|||||||
if self._postgresql.major_version >= 90500:
|
if self._postgresql.major_version >= 90500:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
try:
|
try:
|
||||||
settings_diff: CaseInsensitiveDict = CaseInsensitiveDict()
|
pending_restart = self._postgresql.query(
|
||||||
for param, value, unit, vartype in self._postgresql.query(
|
'SELECT COUNT(*) FROM pg_catalog.pg_settings'
|
||||||
'SELECT name, pg_catalog.current_setting(name), unit, vartype FROM pg_catalog.pg_settings'
|
|
||||||
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
|
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
|
||||||
[n.lower() for n in params_skip_changes]):
|
[n.lower() for n in params_skip_changes])[0][0] > 0
|
||||||
new_value = self._postgresql.get_guc_value(param)
|
self._postgresql.set_pending_restart(pending_restart)
|
||||||
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:
|
except Exception as e:
|
||||||
logger.warning('Exception %r when running query', e)
|
logger.warning('Exception %r when running query', e)
|
||||||
else:
|
else:
|
||||||
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
|
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]:
|
def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]:
|
||||||
"""Updates synchronous_standby_names and reloads if necessary.
|
"""Updates synchronous_standby_names and reloads if necessary.
|
||||||
:returns: True if value was updated."""
|
:returns: True if value was updated."""
|
||||||
@@ -1256,7 +1218,6 @@ class ConfigHandler(object):
|
|||||||
data = self._postgresql.controldata()
|
data = self._postgresql.controldata()
|
||||||
effective_configuration = self._server_parameters.copy()
|
effective_configuration = self._server_parameters.copy()
|
||||||
|
|
||||||
param_diff = CaseInsensitiveDict()
|
|
||||||
for name, cname in options_mapping.items():
|
for name, cname in options_mapping.items():
|
||||||
value = parse_int(effective_configuration[name])
|
value = parse_int(effective_configuration[name])
|
||||||
if cname not in data:
|
if cname not in data:
|
||||||
@@ -1266,10 +1227,7 @@ class ConfigHandler(object):
|
|||||||
cvalue = parse_int(data[cname])
|
cvalue = parse_int(data[cname])
|
||||||
if cvalue is not None and value is not None and cvalue > value:
|
if cvalue is not None and value is not None and cvalue > value:
|
||||||
effective_configuration[name] = cvalue
|
effective_configuration[name] = cvalue
|
||||||
logger.info("%s value in pg_controldata: %d, in the global configuration: %d."
|
self._postgresql.set_pending_restart(True)
|
||||||
" 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
|
# 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'.
|
# are increased, therefore we disable hot_standby if recovery_target_action == 'promote'.
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -176,7 +176,7 @@ class PostmasterProcess(psutil.Process):
|
|||||||
return not self.is_running()
|
return not self.is_running()
|
||||||
|
|
||||||
def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
|
def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
|
||||||
# These regexps are cross checked against versions PostgreSQL 9.1 .. 16
|
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15
|
||||||
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
|
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
|
||||||
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
|
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
|
||||||
"walreceiver|wal sender process|walsender|walwriter|background writer|"
|
"walreceiver|wal sender process|walsender|walwriter|background writer|"
|
||||||
|
|||||||
@@ -209,10 +209,9 @@ class Rewind(object):
|
|||||||
ret = member.conn_kwargs(auth)
|
ret = member.conn_kwargs(auth)
|
||||||
if not ret.get('dbname'):
|
if not ret.get('dbname'):
|
||||||
ret['dbname'] = self._postgresql.database
|
ret['dbname'] = self._postgresql.database
|
||||||
# Add target_session_attrs to make sure we hit the primary.
|
# Add target_session_attrs in case more than one hostname is specified
|
||||||
# It is not strictly necessary for starting from PostgreSQL v14, which made it possible
|
# (libpq client-side failover) making sure we hit the primary
|
||||||
# to rewind from standby, but doing it from the real primary is always safer.
|
if 'target_session_attrs' not in ret and self._postgresql.major_version >= 100000:
|
||||||
if self._postgresql.major_version >= 100000:
|
|
||||||
ret['target_session_attrs'] = 'read-write'
|
ret['target_session_attrs'] = 'read-write'
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|||||||
+22
-19
@@ -13,11 +13,9 @@ from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECK
|
|||||||
|
|
||||||
from .connection import get_connection_cursor
|
from .connection import get_connection_cursor
|
||||||
from .misc import format_lsn, fsync_dir
|
from .misc import format_lsn, fsync_dir
|
||||||
from .. import global_config
|
|
||||||
from ..dcs import Cluster, Leader
|
from ..dcs import Cluster, Leader
|
||||||
from ..file_perm import pg_perm
|
from ..file_perm import pg_perm
|
||||||
from ..psycopg import OperationalError
|
from ..psycopg import OperationalError
|
||||||
from ..tags import Tags
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from psycopg import Cursor
|
from psycopg import Cursor
|
||||||
@@ -291,18 +289,18 @@ class SlotsHandler:
|
|||||||
:param name: name of the slot to ignore
|
:param name: name of the slot to ignore
|
||||||
|
|
||||||
:returns: ``True`` if slot *name* matches any slot specified in ``ignore_slots`` configuration,
|
:returns: ``True`` if slot *name* matches any slot specified in ``ignore_slots`` configuration,
|
||||||
otherwise will pass through and return result of :meth:`AbstractMPPHandler.ignore_replication_slot`.
|
otherwise will pass through and return result of :meth:`CitusHandler.ignore_replication_slot`.
|
||||||
"""
|
"""
|
||||||
slot = self._replication_slots[name]
|
slot = self._replication_slots[name]
|
||||||
if cluster.config:
|
if cluster.config:
|
||||||
for matcher in global_config.ignore_slots_matchers:
|
for matcher in cluster.config.ignore_slots_matchers:
|
||||||
if (
|
if (
|
||||||
(matcher.get("name") is None or matcher["name"] == name)
|
(matcher.get("name") is None or matcher["name"] == name)
|
||||||
and all(not matcher.get(a) or matcher[a] == slot.get(a)
|
and all(not matcher.get(a) or matcher[a] == slot.get(a)
|
||||||
for a in ('database', 'plugin', 'type'))
|
for a in ('database', 'plugin', 'type'))
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
return self._postgresql.mpp_handler.ignore_replication_slot(slot)
|
return self._postgresql.citus_handler.ignore_replication_slot(slot)
|
||||||
|
|
||||||
def drop_replication_slot(self, name: str) -> Tuple[bool, bool]:
|
def drop_replication_slot(self, name: str) -> Tuple[bool, bool]:
|
||||||
"""Drop a named slot from Postgres.
|
"""Drop a named slot from Postgres.
|
||||||
@@ -321,7 +319,7 @@ class SlotsHandler:
|
|||||||
' FULL OUTER JOIN dropped ON true'), name)
|
' FULL OUTER JOIN dropped ON true'), name)
|
||||||
return (rows[0][0], rows[0][1]) if rows else (False, False)
|
return (rows[0][0], rows[0][1]) if rows else (False, False)
|
||||||
|
|
||||||
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any]) -> None:
|
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any], paused: bool) -> None:
|
||||||
"""Compare required slots and configured as permanent slots with those found, dropping extraneous ones.
|
"""Compare required slots and configured as permanent slots with those found, dropping extraneous ones.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
@@ -332,10 +330,11 @@ class SlotsHandler:
|
|||||||
|
|
||||||
:param cluster: cluster state information object.
|
:param cluster: cluster state information object.
|
||||||
:param slots: dictionary of desired slot names as keys with slot attributes as a dictionary value, if known.
|
:param slots: dictionary of desired slot names as keys with slot attributes as a dictionary value, if known.
|
||||||
|
:param paused: ``True`` if the patroni cluster is currently in a paused state.
|
||||||
"""
|
"""
|
||||||
# drop old replication slots which are not presented in desired slots.
|
# drop old replication slots which are not presented in desired slots.
|
||||||
for name in set(self._replication_slots) - set(slots):
|
for name in set(self._replication_slots) - set(slots):
|
||||||
if not global_config.is_paused and not self.ignore_replication_slot(cluster, name):
|
if not paused and not self.ignore_replication_slot(cluster, name):
|
||||||
active, dropped = self.drop_replication_slot(name)
|
active, dropped = self.drop_replication_slot(name)
|
||||||
if dropped:
|
if dropped:
|
||||||
logger.info("Dropped unknown replication slot '%s'", name)
|
logger.info("Dropped unknown replication slot '%s'", name)
|
||||||
@@ -493,7 +492,8 @@ class SlotsHandler:
|
|||||||
self._schedule_load_slots = True
|
self._schedule_load_slots = True
|
||||||
return create_slots + copy_slots
|
return create_slots + copy_slots
|
||||||
|
|
||||||
def sync_replication_slots(self, cluster: Cluster, tags: Tags) -> List[str]:
|
def sync_replication_slots(self, cluster: Cluster, nofailover: bool,
|
||||||
|
replicatefrom: Optional[str] = None, paused: bool = False) -> List[str]:
|
||||||
"""During the HA loop read, check and alter replication slots found in the cluster.
|
"""During the HA loop read, check and alter replication slots found in the cluster.
|
||||||
|
|
||||||
Read physical and logical slots from ``pg_replication_slots``, then compare to those configured in the DCS.
|
Read physical and logical slots from ``pg_replication_slots``, then compare to those configured in the DCS.
|
||||||
@@ -503,18 +503,22 @@ class SlotsHandler:
|
|||||||
them on replica nodes by copying slot files from the primary.
|
them on replica nodes by copying slot files from the primary.
|
||||||
|
|
||||||
:param cluster: object containing stateful information for the cluster.
|
:param cluster: object containing stateful information for the cluster.
|
||||||
:param tags: reference to an object implementing :class:`Tags` interface.
|
:param nofailover: ``True`` if this node has been tagged to not be a failover candidate.
|
||||||
|
:param replicatefrom: the tag containing the node to replicate from.
|
||||||
|
:param paused: ``True`` if the cluster is in maintenance mode.
|
||||||
|
|
||||||
:returns: list of logical replication slots names that should be copied from the primary.
|
:returns: list of logical replication slots names that should be copied from the primary.
|
||||||
"""
|
"""
|
||||||
ret = []
|
ret = []
|
||||||
if self._postgresql.major_version >= 90400 and cluster.config:
|
if self._postgresql.major_version >= 90400 and self._postgresql.global_config and cluster.config:
|
||||||
try:
|
try:
|
||||||
self.load_replication_slots()
|
self.load_replication_slots()
|
||||||
|
|
||||||
slots = cluster.get_replication_slots(self._postgresql, tags, show_error=True)
|
slots = cluster.get_replication_slots(
|
||||||
|
self._postgresql.name, self._postgresql.role, nofailover, self._postgresql.major_version,
|
||||||
|
is_standby_cluster=self._postgresql.global_config.is_standby_cluster, show_error=True)
|
||||||
|
|
||||||
self._drop_incorrect_slots(cluster, slots)
|
self._drop_incorrect_slots(cluster, slots, paused)
|
||||||
|
|
||||||
self._ensure_physical_slots(slots)
|
self._ensure_physical_slots(slots)
|
||||||
|
|
||||||
@@ -522,7 +526,7 @@ class SlotsHandler:
|
|||||||
self._logical_slots_processing_queue.clear()
|
self._logical_slots_processing_queue.clear()
|
||||||
self._ensure_logical_slots_primary(slots)
|
self._ensure_logical_slots_primary(slots)
|
||||||
else:
|
else:
|
||||||
self.check_logical_slots_readiness(cluster, tags)
|
self.check_logical_slots_readiness(cluster, replicatefrom)
|
||||||
ret = self._ensure_logical_slots_replica(slots)
|
ret = self._ensure_logical_slots_replica(slots)
|
||||||
|
|
||||||
self._replication_slots = slots
|
self._replication_slots = slots
|
||||||
@@ -548,7 +552,7 @@ class SlotsHandler:
|
|||||||
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
|
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
|
||||||
yield cur
|
yield cur
|
||||||
|
|
||||||
def check_logical_slots_readiness(self, cluster: Cluster, tags: Tags) -> bool:
|
def check_logical_slots_readiness(self, cluster: Cluster, replicatefrom: Optional[str]) -> bool:
|
||||||
"""Determine whether all known logical slots are synchronised from the leader.
|
"""Determine whether all known logical slots are synchronised from the leader.
|
||||||
|
|
||||||
1) Retrieve the current ``catalog_xmin`` value for the physical slot from the cluster leader, and
|
1) Retrieve the current ``catalog_xmin`` value for the physical slot from the cluster leader, and
|
||||||
@@ -557,13 +561,13 @@ class SlotsHandler:
|
|||||||
3) store logical slot ``catalog_xmin`` when the physical slot ``catalog_xmin`` becomes valid.
|
3) store logical slot ``catalog_xmin`` when the physical slot ``catalog_xmin`` becomes valid.
|
||||||
|
|
||||||
:param cluster: object containing stateful information for the cluster.
|
:param cluster: object containing stateful information for the cluster.
|
||||||
:param tags: reference to an object implementing :class:`Tags` interface.
|
:param replicatefrom: name of the member that should be used to replicate from.
|
||||||
|
|
||||||
:returns: ``False`` if any issue while checking logical slots readiness, ``True`` otherwise.
|
:returns: ``False`` if any issue while checking logical slots readiness, ``True`` otherwise.
|
||||||
"""
|
"""
|
||||||
catalog_xmin = None
|
catalog_xmin = None
|
||||||
if self._logical_slots_processing_queue and cluster.leader:
|
if self._logical_slots_processing_queue and cluster.leader:
|
||||||
slot_name = cluster.get_slot_name_on_primary(self._postgresql.name, tags)
|
slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom)
|
||||||
try:
|
try:
|
||||||
with self._get_leader_connection_cursor(cluster.leader) as cur:
|
with self._get_leader_connection_cursor(cluster.leader) as cur:
|
||||||
cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
|
cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
|
||||||
@@ -641,17 +645,16 @@ class SlotsHandler:
|
|||||||
if standby_logical_slot:
|
if standby_logical_slot:
|
||||||
logger.info('Logical slot %s is safe to be used after a failover', name)
|
logger.info('Logical slot %s is safe to be used after a failover', name)
|
||||||
|
|
||||||
def copy_logical_slots(self, cluster: Cluster, tags: Tags, create_slots: List[str]) -> None:
|
def copy_logical_slots(self, cluster: Cluster, create_slots: List[str]) -> None:
|
||||||
"""Create logical replication slots on standby nodes.
|
"""Create logical replication slots on standby nodes.
|
||||||
|
|
||||||
:param cluster: object containing stateful information for the cluster.
|
:param cluster: object containing stateful information for the cluster.
|
||||||
:param tags: reference to an object implementing :class:`Tags` interface.
|
|
||||||
:param create_slots: list of slot names to copy from the primary.
|
:param create_slots: list of slot names to copy from the primary.
|
||||||
"""
|
"""
|
||||||
leader = cluster.leader
|
leader = cluster.leader
|
||||||
if not leader:
|
if not leader:
|
||||||
return
|
return
|
||||||
slots = cluster.get_replication_slots(self._postgresql, tags, role='replica')
|
slots = cluster.get_replication_slots(self._postgresql.name, 'replica', False, self._postgresql.major_version)
|
||||||
copy_slots: Dict[str, Dict[str, Any]] = {}
|
copy_slots: Dict[str, Dict[str, Any]] = {}
|
||||||
with self._get_leader_connection_cursor(leader) as cur:
|
with self._get_leader_connection_cursor(leader) as cur:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+27
-92
@@ -3,9 +3,8 @@ import re
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Collection, List, NamedTuple, Optional, TYPE_CHECKING
|
from typing import Collection, List, NamedTuple, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
from .. import global_config
|
|
||||||
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||||
from ..dcs import Cluster
|
from ..dcs import Cluster
|
||||||
from ..psycopg import quote_ident as _quote_ident
|
from ..psycopg import quote_ident as _quote_ident
|
||||||
@@ -138,7 +137,7 @@ def parse_sync_standby_names(value: str) -> _SSN:
|
|||||||
if len(synclist) == i + 1: # except the last token
|
if len(synclist) == i + 1: # except the last token
|
||||||
raise ValueError("Unparseable synchronous_standby_names value %r: Unexpected token %s %r at %d" %
|
raise ValueError("Unparseable synchronous_standby_names value %r: Unexpected token %s %r at %d" %
|
||||||
(value, a_type, a_value, a_pos))
|
(value, a_type, a_value, a_pos))
|
||||||
if a_type != 'comma':
|
elif a_type != 'comma':
|
||||||
raise ValueError("Unparseable synchronous_standby_names value %r: ""Got token %s %r while"
|
raise ValueError("Unparseable synchronous_standby_names value %r: ""Got token %s %r while"
|
||||||
" expecting comma at %d" % (value, a_type, a_value, a_pos))
|
" expecting comma at %d" % (value, a_type, a_value, a_pos))
|
||||||
elif a_type in {'ident', 'first', 'any'}:
|
elif a_type in {'ident', 'first', 'any'}:
|
||||||
@@ -154,26 +153,6 @@ def parse_sync_standby_names(value: str) -> _SSN:
|
|||||||
return _SSN(sync_type, has_star, num, members)
|
return _SSN(sync_type, has_star, num, members)
|
||||||
|
|
||||||
|
|
||||||
class _SyncState(NamedTuple):
|
|
||||||
"""Class representing the current synchronous state.
|
|
||||||
|
|
||||||
:ivar sync_type: possible values: ``off``, ``priority``, ``quorum``
|
|
||||||
:ivar numsync: how many nodes are required to be synchronous (according to ``synchronous_standby_names``).
|
|
||||||
Is ``0`` if ``synchronous_standby_names`` value is invalid or contains ``*``.
|
|
||||||
:ivar numsync_confirmed: how many nodes are known to be synchronous according to the ``pg_stat_replication`` view.
|
|
||||||
Only nodes that caught up with the :attr:`SyncHandler._primary_flush_lsn` are counted.
|
|
||||||
:ivar sync: collection of synchronous node names. In case of quorum commit all nodes listed
|
|
||||||
in ``synchronous_standby_names``, otherwise nodes that are confirmed to be synchronous according
|
|
||||||
to the ``pg_stat_replication`` view.
|
|
||||||
:ivar active: collection of node names that are streaming and have no restrictions to become synchronous.
|
|
||||||
"""
|
|
||||||
sync_type: str
|
|
||||||
numsync: int
|
|
||||||
numsync_confirmed: int
|
|
||||||
sync: CaseInsensitiveSet
|
|
||||||
active: CaseInsensitiveSet
|
|
||||||
|
|
||||||
|
|
||||||
class _Replica(NamedTuple):
|
class _Replica(NamedTuple):
|
||||||
"""Class representing a single replica that is eligible to be synchronous.
|
"""Class representing a single replica that is eligible to be synchronous.
|
||||||
|
|
||||||
@@ -237,8 +216,6 @@ class _ReplicaList(List[_Replica]):
|
|||||||
# Prefer replicas that are in state ``sync`` and with higher values of ``write``/``flush``/``replay`` LSN.
|
# Prefer replicas that are in state ``sync`` and with higher values of ``write``/``flush``/``replay`` LSN.
|
||||||
self.sort(key=lambda r: (r.sync_state, r.lsn), reverse=True)
|
self.sort(key=lambda r: (r.sync_state, r.lsn), reverse=True)
|
||||||
|
|
||||||
# When checking ``maximum_lag_on_syncnode`` we want to compare with the most
|
|
||||||
# up-to-date replica otherwise with cluster LSN if there is only one replica.
|
|
||||||
self.max_lsn = max(self, key=lambda x: x.lsn).lsn if len(self) > 1 else postgresql.last_operation()
|
self.max_lsn = max(self, key=lambda x: x.lsn).lsn if len(self) > 1 else postgresql.last_operation()
|
||||||
|
|
||||||
|
|
||||||
@@ -300,22 +277,12 @@ END;$$""")
|
|||||||
# if standby name is listed in the /sync key we can count it as synchronous, otherwise
|
# if standby name is listed in the /sync key we can count it as synchronous, otherwise
|
||||||
# it becomes really synchronous when sync_state = 'sync' and it is known that it managed to catch up
|
# it becomes really synchronous when sync_state = 'sync' and it is known that it managed to catch up
|
||||||
if replica.application_name not in self._ready_replicas\
|
if replica.application_name not in self._ready_replicas\
|
||||||
and replica.application_name in self._ssn_data.members:
|
and replica.application_name in self._ssn_data.members\
|
||||||
if global_config.is_quorum_commit_mode:
|
and (cluster.sync.matches(replica.application_name)
|
||||||
# When quorum commit is enabled we can't check against cluster.sync because nodes
|
or replica.sync_state == 'sync' and replica.lsn >= self._primary_flush_lsn):
|
||||||
# are written there when at least one of them caught up with _primary_flush_lsn.
|
|
||||||
if replica.lsn >= self._primary_flush_lsn\
|
|
||||||
and (replica.sync_state == 'quorum'
|
|
||||||
or (not self._postgresql.supports_quorum_commit
|
|
||||||
and replica.sync_state in ('sync', 'potential'))):
|
|
||||||
self._ready_replicas[replica.application_name] = replica.pid
|
|
||||||
elif cluster.sync.matches(replica.application_name)\
|
|
||||||
or replica.sync_state == 'sync' and replica.lsn >= self._primary_flush_lsn:
|
|
||||||
# if standby name is listed in the /sync key we can count it as synchronous, otherwise it becomes
|
|
||||||
# "really" synchronous when sync_state = 'sync' and we known that it managed to catch up
|
|
||||||
self._ready_replicas[replica.application_name] = replica.pid
|
self._ready_replicas[replica.application_name] = replica.pid
|
||||||
|
|
||||||
def current_state(self, cluster: Cluster) -> _SyncState:
|
def current_state(self, cluster: Cluster) -> Tuple[CaseInsensitiveSet, CaseInsensitiveSet]:
|
||||||
"""Find the best candidates to be the synchronous standbys.
|
"""Find the best candidates to be the synchronous standbys.
|
||||||
|
|
||||||
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
|
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
|
||||||
@@ -323,86 +290,54 @@ END;$$""")
|
|||||||
|
|
||||||
Standbys are selected based on values from the global configuration:
|
Standbys are selected based on values from the global configuration:
|
||||||
|
|
||||||
- ``maximum_lag_on_syncnode``: would help swapping unhealthy sync replica in case it stops
|
- `maximum_lag_on_syncnode`: would help swapping unhealthy sync replica in case if it stops
|
||||||
responding (or hung). Please set the value high enough, so it won't unnecessarily swap sync
|
responding (or hung). Please set the value high enough so it won't unncessarily swap sync
|
||||||
standbys during high loads. Any value less or equal to ``0`` keeps the behavior backwards compatible.
|
standbys during high loads. Any value less or equal of 0 keeps the behavior backward compatible.
|
||||||
Please note that it will also not swap sync standbys when all replicas are hung.
|
Please note that it will not also swap sync standbys in case where all replicas are hung.
|
||||||
|
- `synchronous_node_count`: controlls how many nodes should be set as synchronous.
|
||||||
|
|
||||||
- ``synchronous_node_count``: controls how many nodes should be set as synchronous.
|
:returns: tuple of candidates :class:`CaseInsensitiveSet` and synchronous standbys :class:`CaseInsensitiveSet`.
|
||||||
|
|
||||||
:param cluster: current cluster topology from DCS
|
|
||||||
|
|
||||||
:returns: current synchronous replication state as a :class:`_SyncState` object
|
|
||||||
"""
|
"""
|
||||||
self._handle_synchronous_standby_names_change()
|
self._handle_synchronous_standby_names_change()
|
||||||
|
|
||||||
replica_list = _ReplicaList(self._postgresql, cluster)
|
replica_list = _ReplicaList(self._postgresql, cluster)
|
||||||
self._process_replica_readiness(cluster, replica_list)
|
self._process_replica_readiness(cluster, replica_list)
|
||||||
|
|
||||||
active = CaseInsensitiveSet()
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
assert self._postgresql.global_config is not None
|
||||||
|
sync_node_count = self._postgresql.global_config.synchronous_node_count\
|
||||||
|
if self._postgresql.supports_multiple_sync else 1
|
||||||
|
sync_node_maxlag = self._postgresql.global_config.maximum_lag_on_syncnode
|
||||||
|
|
||||||
|
candidates = CaseInsensitiveSet()
|
||||||
sync_nodes = CaseInsensitiveSet()
|
sync_nodes = CaseInsensitiveSet()
|
||||||
numsync_confirmed = 0
|
|
||||||
|
|
||||||
sync_node_count = global_config.synchronous_node_count if self._postgresql.supports_multiple_sync else 1
|
|
||||||
sync_node_maxlag = global_config.maximum_lag_on_syncnode
|
|
||||||
|
|
||||||
# Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable.
|
# Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable.
|
||||||
for replica in sorted(replica_list, key=lambda x: x.nofailover):
|
for replica in sorted(replica_list, key=lambda x: x.nofailover):
|
||||||
if sync_node_maxlag <= 0 or replica_list.max_lsn - replica.lsn <= sync_node_maxlag:
|
if sync_node_maxlag <= 0 or replica_list.max_lsn - replica.lsn <= sync_node_maxlag:
|
||||||
if global_config.is_quorum_commit_mode:
|
candidates.add(replica.application_name)
|
||||||
# We do not add nodes with `nofailover` enabled because that reduces availability.
|
|
||||||
# We need to check LSN quorum only among nodes that are promotable because
|
|
||||||
# there is a chance that a non-promotable node is ahead of a promotable one.
|
|
||||||
if not replica.nofailover or len(active) < sync_node_count:
|
|
||||||
if replica.application_name in self._ready_replicas:
|
|
||||||
numsync_confirmed += 1
|
|
||||||
active.add(replica.application_name)
|
|
||||||
else:
|
|
||||||
active.add(replica.application_name)
|
|
||||||
if replica.sync_state == 'sync' and replica.application_name in self._ready_replicas:
|
if replica.sync_state == 'sync' and replica.application_name in self._ready_replicas:
|
||||||
sync_nodes.add(replica.application_name)
|
sync_nodes.add(replica.application_name)
|
||||||
numsync_confirmed += 1
|
if len(candidates) >= sync_node_count:
|
||||||
if len(active) >= sync_node_count:
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if global_config.is_quorum_commit_mode:
|
return candidates, sync_nodes
|
||||||
sync_nodes = CaseInsensitiveSet() if self._ssn_data.has_star else self._ssn_data.members
|
|
||||||
|
|
||||||
return _SyncState(
|
def set_synchronous_standby_names(self, sync: Collection[str]) -> None:
|
||||||
self._ssn_data.sync_type,
|
"""Constructs and sets "synchronous_standby_names" GUC value.
|
||||||
0 if self._ssn_data.has_star else self._ssn_data.num,
|
|
||||||
numsync_confirmed,
|
|
||||||
sync_nodes,
|
|
||||||
active)
|
|
||||||
|
|
||||||
def set_synchronous_standby_names(self, sync: Collection[str], num: Optional[int] = None) -> None:
|
|
||||||
"""Constructs and sets ``synchronous_standby_names`` GUC value.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
standbys in ``synchronous_standby_names`` will be sorted by name.
|
|
||||||
|
|
||||||
:param sync: set of nodes to sync to
|
:param sync: set of nodes to sync to
|
||||||
:param num: specifies number of nodes to sync to. The *num* is set only in case if quorum commit is enabled
|
|
||||||
"""
|
"""
|
||||||
# Special case. If sync nodes set is empty but requested num of sync nodes >= 1
|
has_asterisk = '*' in sync
|
||||||
# we want to set synchronous_standby_names to '*'
|
|
||||||
has_asterisk = '*' in sync or num and num >= 1 and not sync
|
|
||||||
if has_asterisk:
|
if has_asterisk:
|
||||||
sync = ['*']
|
sync = ['*']
|
||||||
else:
|
else:
|
||||||
sync = [quote_ident(x) for x in sorted(sync)]
|
sync = [quote_ident(x) for x in sync]
|
||||||
|
|
||||||
if self._postgresql.supports_multiple_sync and len(sync) > 1:
|
if self._postgresql.supports_multiple_sync and len(sync) > 1:
|
||||||
if num is None:
|
sync_param = '{0} ({1})'.format(len(sync), ','.join(sync))
|
||||||
num = len(sync)
|
|
||||||
sync_param = ','.join(sync)
|
|
||||||
else:
|
else:
|
||||||
sync_param = next(iter(sync), None)
|
sync_param = next(iter(sync), None)
|
||||||
|
|
||||||
if global_config.is_quorum_commit_mode and sync or self._postgresql.supports_multiple_sync and len(sync) > 1:
|
|
||||||
prefix = 'ANY ' if global_config.is_quorum_commit_mode and self._postgresql.supports_quorum_commit else ''
|
|
||||||
sync_param = f'{prefix}{num} ({sync_param})'
|
|
||||||
|
|
||||||
if not (self._postgresql.config.set_synchronous_standby_names(sync_param)
|
if not (self._postgresql.config.set_synchronous_standby_names(sync_param)
|
||||||
and self._postgresql.state == 'running' and self._postgresql.is_primary()) or has_asterisk:
|
and self._postgresql.state == 'running' and self._postgresql.is_primary()) or has_asterisk:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import abc
|
import abc
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from typing import Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Type, Union
|
from typing import Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Type, Union
|
||||||
|
|
||||||
from .available_parameters import get_validator_files, PathLikeObj
|
|
||||||
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
|
||||||
from ..exceptions import PatroniException
|
from ..exceptions import PatroniException
|
||||||
from ..utils import parse_bool, parse_int, parse_real
|
from ..utils import parse_bool, parse_int, parse_real
|
||||||
@@ -258,10 +258,10 @@ class InvalidGucValidatorsFile(PatroniException):
|
|||||||
"""Raised when reading or parsing of a YAML file faces an issue."""
|
"""Raised when reading or parsing of a YAML file faces an issue."""
|
||||||
|
|
||||||
|
|
||||||
def _read_postgres_gucs_validators_file(file: PathLikeObj) -> Dict[str, Any]:
|
def _read_postgres_gucs_validators_file(file: str) -> Dict[str, Any]:
|
||||||
"""Read an YAML file and return the corresponding Python object.
|
"""Read an YAML file and return the corresponding Python object.
|
||||||
|
|
||||||
:param file: path-like object to read from. It is expected to be encoded with ``UTF-8``, and to be a YAML document.
|
:param file: path to the file to be read. It is expected to be encoded with ``UTF-8``, and to be a YAML document.
|
||||||
|
|
||||||
:returns: the YAML content parsed into a Python object. If any issue is faced while reading/parsing the file, then
|
:returns: the YAML content parsed into a Python object. If any issue is faced while reading/parsing the file, then
|
||||||
return ``None``.
|
return ``None``.
|
||||||
@@ -270,7 +270,7 @@ def _read_postgres_gucs_validators_file(file: PathLikeObj) -> Dict[str, Any]:
|
|||||||
:class:`InvalidGucValidatorsFile`: if faces an issue while reading or parsing *file*.
|
:class:`InvalidGucValidatorsFile`: if faces an issue while reading or parsing *file*.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with file.open(encoding='UTF-8') as stream:
|
with open(file, encoding='UTF-8') as stream:
|
||||||
return yaml.safe_load(stream)
|
return yaml.safe_load(stream)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise InvalidGucValidatorsFile(
|
raise InvalidGucValidatorsFile(
|
||||||
@@ -385,7 +385,21 @@ def _load_postgres_gucs_validators() -> None:
|
|||||||
version_till: null
|
version_till: null
|
||||||
|
|
||||||
"""
|
"""
|
||||||
for file in get_validator_files():
|
conf_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
'available_parameters',
|
||||||
|
)
|
||||||
|
yaml_files: List[str] = []
|
||||||
|
|
||||||
|
for root, _, files in os.walk(conf_dir):
|
||||||
|
for file in sorted(files):
|
||||||
|
full_path = os.path.join(root, file)
|
||||||
|
if file.lower().endswith(('.yml', '.yaml')):
|
||||||
|
yaml_files.append(full_path)
|
||||||
|
else:
|
||||||
|
logger.info('Ignored a non-YAML file found under `available_parameters` directory: `%s`.', full_path)
|
||||||
|
|
||||||
|
for file in yaml_files:
|
||||||
try:
|
try:
|
||||||
config: Dict[str, Any] = _read_postgres_gucs_validators_file(file)
|
config: Dict[str, Any] = _read_postgres_gucs_validators_file(file)
|
||||||
except InvalidGucValidatorsFile as exc:
|
except InvalidGucValidatorsFile as exc:
|
||||||
|
|||||||
@@ -1,431 +0,0 @@
|
|||||||
"""Implement state machine to manage ``synchronous_standby_names`` GUC and ``/sync`` key in DCS."""
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from typing import Collection, Iterator, NamedTuple, Optional
|
|
||||||
|
|
||||||
from .collections import CaseInsensitiveSet
|
|
||||||
from .exceptions import PatroniException
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Transition(NamedTuple):
|
|
||||||
"""Object describing transition of ``/sync`` or ``synchronous_standby_names`` to the new state.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
Object attributes represent the new state.
|
|
||||||
|
|
||||||
:ivar transition_type: possible values:
|
|
||||||
|
|
||||||
* ``sync`` - indicates that we needed to update ``synchronous_standby_names``.
|
|
||||||
* ``quorum`` - indicates that we need to update ``/sync`` key in DCS.
|
|
||||||
* ``restart`` - caller should stop iterating over transitions and restart :class:`QuorumStateResolver`.
|
|
||||||
:ivar leader: the new value of the ``leader`` field in the ``/sync`` key.
|
|
||||||
:ivar num: the new value of the synchronous nodes count in ``synchronous_standby_names`` or value of the ``quorum``
|
|
||||||
field in the ``/sync`` key for :attr:`transition_type` values ``sync`` and ``quorum`` respectively.
|
|
||||||
:ivar names: the new value of node names listed in ``synchronous_standby_names`` or value of ``voters``
|
|
||||||
field in the ``/sync`` key for :attr:`transition_type` values ``sync`` and ``quorum`` respectively.
|
|
||||||
"""
|
|
||||||
|
|
||||||
transition_type: str
|
|
||||||
leader: str
|
|
||||||
num: int
|
|
||||||
names: CaseInsensitiveSet
|
|
||||||
|
|
||||||
|
|
||||||
class QuorumError(PatroniException):
|
|
||||||
"""Exception indicating that the quorum state is broken."""
|
|
||||||
|
|
||||||
|
|
||||||
class QuorumStateResolver:
|
|
||||||
"""Calculates a list of state transitions and yields them as :class:`Transition` named tuples.
|
|
||||||
|
|
||||||
Synchronous replication state is set in two places:
|
|
||||||
|
|
||||||
* PostgreSQL configuration sets how many and which nodes are needed for a commit to succeed, abbreviated as
|
|
||||||
``numsync`` and ``sync`` set here;
|
|
||||||
* DCS contains information about how many and which nodes need to be interrogated to be sure to see an wal position
|
|
||||||
containing latest confirmed commit, abbreviated as ``quorum`` and ``voters`` set.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
Both of above pairs have the meaning "ANY n OF set".
|
|
||||||
|
|
||||||
The number of nodes needed for commit to succeed, ``numsync``, is also called the replication factor.
|
|
||||||
|
|
||||||
To guarantee zero transaction loss on failover we need to keep the invariant that at all times any subset of
|
|
||||||
nodes that can acknowledge a commit overlaps with any subset of nodes that can achieve quorum to promote a new
|
|
||||||
leader. Given a desired replication factor and a set of nodes able to participate in sync replication there
|
|
||||||
is one optimal state satisfying this condition. Given the node set ``active``, the optimal state is::
|
|
||||||
|
|
||||||
sync = voters = active
|
|
||||||
|
|
||||||
numsync = min(sync_wanted, len(active))
|
|
||||||
|
|
||||||
quorum = len(active) - numsync
|
|
||||||
|
|
||||||
We need to be able to produce a series of state changes that take the system to this desired state from any
|
|
||||||
other arbitrary state given arbitrary changes is node availability, configuration and interrupted transitions.
|
|
||||||
|
|
||||||
To keep the invariant the rule to follow is that when increasing ``numsync`` or ``quorum``, we need to perform the
|
|
||||||
increasing operation first. When decreasing either, the decreasing operation needs to be performed later. In other
|
|
||||||
words:
|
|
||||||
|
|
||||||
* If a user increases ``synchronous_node_count`` configuration, first we increase ``synchronous_standby_names``
|
|
||||||
(``numsync``), then we decrease ``quorum`` field in the ``/sync`` key;
|
|
||||||
* If a user decreases ``synchronous_node_count`` configuration, first we increase ``quorum`` field in the ``/sync``
|
|
||||||
key, then we decrease ``synchronous_standby_names`` (``numsync``).
|
|
||||||
|
|
||||||
Order of adding or removing nodes from ``sync`` and ``voters`` depends on the state of
|
|
||||||
``synchronous_standby_names``.
|
|
||||||
|
|
||||||
When adding new nodes::
|
|
||||||
|
|
||||||
if ``sync`` (``synchronous_standby_names``) is empty:
|
|
||||||
add new nodes first to ``sync`` and then to ``voters`` when ``numsync_confirmed`` > ``0``.
|
|
||||||
else:
|
|
||||||
add new nodes first to ``voters`` and then to ``sync``.
|
|
||||||
|
|
||||||
When removing nodes::
|
|
||||||
|
|
||||||
if ``sync`` (``synchronous_standby_names``) will become empty after removal:
|
|
||||||
first remove nodes from ``voters`` and then from ``sync``.
|
|
||||||
else:
|
|
||||||
first remove nodes from ``sync`` and then from ``voters``.
|
|
||||||
Make ``voters`` empty if ``numsync_confirmed`` == ``0``.
|
|
||||||
|
|
||||||
:ivar leader: name of the leader, according to the ``/sync`` key.
|
|
||||||
:ivar quorum: ``quorum`` value from the ``/sync`` key, the minimal number of nodes we need see
|
|
||||||
when doing the leader race.
|
|
||||||
:ivar voters: ``sync_standby`` value from the ``/sync`` key, set of node names we will be
|
|
||||||
running the leader race against.
|
|
||||||
:ivar numsync: the number of synchronous nodes from the ``synchronous_standby_names``.
|
|
||||||
:ivar sync: set of node names listed in the ``synchronous_standby_names``.
|
|
||||||
:ivar numsync_confirmed: the number of nodes that are confirmed to reach "safe" LSN after they were added to the
|
|
||||||
``synchronous_standby_names``.
|
|
||||||
:ivar active: set of node names that are replicating from the primary (according to ``pg_stat_replication``)
|
|
||||||
and are eligible to be listed in ``synchronous_standby_names``.
|
|
||||||
:ivar sync_wanted: desired number of synchronous nodes (``synchronous_node_count`` from the global configuration).
|
|
||||||
:ivar leader_wanted: the desired leader (could be different from the :attr:`leader` right after a failover).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, leader: str, quorum: int, voters: Collection[str],
|
|
||||||
numsync: int, sync: Collection[str], numsync_confirmed: int,
|
|
||||||
active: Collection[str], sync_wanted: int, leader_wanted: str) -> None:
|
|
||||||
"""Instantiate :class:``QuorumStateResolver`` based on input parameters.
|
|
||||||
|
|
||||||
:param leader: name of the leader, according to the ``/sync`` key.
|
|
||||||
:param quorum: ``quorum`` value from the ``/sync`` key, the minimal number of nodes we need see
|
|
||||||
when doing the leader race.
|
|
||||||
:param voters: ``sync_standby`` value from the ``/sync`` key, set of node names we will be
|
|
||||||
running the leader race against.
|
|
||||||
:param numsync: the number of synchronous nodes from the ``synchronous_standby_names``.
|
|
||||||
:param sync: Set of node names listed in the ``synchronous_standby_names``.
|
|
||||||
:param numsync_confirmed: the number of nodes that are confirmed to reach "safe" LSN after
|
|
||||||
they were added to the ``synchronous_standby_names``.
|
|
||||||
:param active: set of node names that are replicating from the primary (according to ``pg_stat_replication``)
|
|
||||||
and are eligible to be listed in ``synchronous_standby_names``.
|
|
||||||
:param sync_wanted: desired number of synchronous nodes
|
|
||||||
(``synchronous_node_count`` from the global configuration).
|
|
||||||
:param leader_wanted: the desired leader (could be different from the *leader* right after a failover).
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.leader = leader
|
|
||||||
self.quorum = quorum
|
|
||||||
self.voters = CaseInsensitiveSet(voters)
|
|
||||||
self.numsync = min(numsync, len(sync)) # numsync can't be bigger than number of listed synchronous nodes.
|
|
||||||
self.sync = CaseInsensitiveSet(sync)
|
|
||||||
self.numsync_confirmed = numsync_confirmed
|
|
||||||
self.active = CaseInsensitiveSet(active)
|
|
||||||
self.sync_wanted = sync_wanted
|
|
||||||
self.leader_wanted = leader_wanted
|
|
||||||
|
|
||||||
def check_invariants(self) -> None:
|
|
||||||
"""Checks invariant of ``synchronous_standby_names`` and ``/sync`` key in DCS.
|
|
||||||
|
|
||||||
.. seealso::
|
|
||||||
Check :class:`QuorumStateResolver`'s docstring for more information.
|
|
||||||
|
|
||||||
:raises:
|
|
||||||
:exc:`QuorumError`: in case of broken state"""
|
|
||||||
voters = CaseInsensitiveSet(self.voters | CaseInsensitiveSet([self.leader]))
|
|
||||||
sync = CaseInsensitiveSet(self.sync | CaseInsensitiveSet([self.leader_wanted]))
|
|
||||||
|
|
||||||
# We need to verify that subset of nodes that can acknowledge a commit overlaps
|
|
||||||
# with any subset of nodes that can achieve quorum to promote a new leader.
|
|
||||||
# ``+ 1`` is required because the leader is included in the set.
|
|
||||||
if self.voters and not (len(voters | sync) <= self.quorum + self.numsync + 1):
|
|
||||||
len_nodes = len(voters | sync)
|
|
||||||
raise QuorumError("Quorum and sync not guaranteed to overlap: "
|
|
||||||
f"nodes {len_nodes} >= quorum {self.quorum} + sync {self.sync} + 1")
|
|
||||||
# unstable cases, we are changing synchronous_standby_names and /sync key
|
|
||||||
# one after another, hence one set is allowed to be a subset of another
|
|
||||||
if not (voters.issubset(sync) or sync.issubset(voters)):
|
|
||||||
voters_only = voters - sync
|
|
||||||
sync_only = sync - voters
|
|
||||||
raise QuorumError(f"Mismatched sets: voter only={voters_only} sync only={sync_only}")
|
|
||||||
|
|
||||||
def quorum_update(self, quorum: int, voters: CaseInsensitiveSet, leader: Optional[str] = None,
|
|
||||||
adjust_quorum: Optional[bool] = True) -> Iterator[Transition]:
|
|
||||||
"""Updates :attr:`quorum`, :attr:`voters` and optionally :attr:`leader` fields.
|
|
||||||
|
|
||||||
:param quorum: the new value for :attr:`quorum`, could be adjusted depending
|
|
||||||
on values of :attr:`numsync_confirmed` and *adjust_quorum*.
|
|
||||||
:param voters: the new value for :attr:`voters`, could be adjusted if :attr:`numsync_confirmed` == ``0``.
|
|
||||||
:param leader: the new value for :attr:`leader`, optional.
|
|
||||||
:param adjust_quorum: if set to ``True`` the quorum requirement will be increased by the
|
|
||||||
difference between :attr:`numsync` and :attr:`numsync_confirmed`.
|
|
||||||
|
|
||||||
:yields: the new state of the ``/sync`` key as a :class:`Transition` object.
|
|
||||||
|
|
||||||
:raises:
|
|
||||||
:exc:`QuorumError` in case of invalid data or if the invariant after transition could not be satisfied.
|
|
||||||
"""
|
|
||||||
if quorum < 0:
|
|
||||||
raise QuorumError(f'Quorum {quorum} < 0 of ({voters})')
|
|
||||||
if quorum > 0 and quorum >= len(voters):
|
|
||||||
raise QuorumError(f'Quorum {quorum} >= N of ({voters})')
|
|
||||||
|
|
||||||
old_leader = self.leader
|
|
||||||
if leader is not None: # Change of leader was requested
|
|
||||||
self.leader = leader
|
|
||||||
elif self.numsync_confirmed == 0:
|
|
||||||
# If there are no nodes that known to caught up with the primary we want to reset quorum/voters in /sync key
|
|
||||||
quorum = 0
|
|
||||||
voters = CaseInsensitiveSet()
|
|
||||||
elif adjust_quorum:
|
|
||||||
# It could be that the number of nodes that are known to catch up with the primary is below desired numsync.
|
|
||||||
# We want to increase quorum to guarantee that the sync node will be found during the leader race.
|
|
||||||
quorum += max(self.numsync - self.numsync_confirmed, 0)
|
|
||||||
|
|
||||||
if (self.leader, quorum, voters) == (old_leader, self.quorum, self.voters):
|
|
||||||
if self.voters:
|
|
||||||
return
|
|
||||||
# If transition produces no change of leader/quorum/voters we want to give a hint to
|
|
||||||
# the caller to fetch the new state from the database and restart QuorumStateResolver.
|
|
||||||
yield Transition('restart', self.leader, self.quorum, self.voters)
|
|
||||||
|
|
||||||
self.quorum = quorum
|
|
||||||
self.voters = voters
|
|
||||||
self.check_invariants()
|
|
||||||
logger.debug('quorum %s %s %s', self.leader, self.quorum, self.voters)
|
|
||||||
yield Transition('quorum', self.leader, self.quorum, self.voters)
|
|
||||||
|
|
||||||
def sync_update(self, numsync: int, sync: CaseInsensitiveSet) -> Iterator[Transition]:
|
|
||||||
"""Updates :attr:`numsync` and :attr:`sync` fields.
|
|
||||||
|
|
||||||
:param numsync: the new value for :attr:`numsync`.
|
|
||||||
:param sync: the new value for :attr:`sync`:
|
|
||||||
|
|
||||||
:yields: the new state of ``synchronous_standby_names`` as a :class:`Transition` object.
|
|
||||||
|
|
||||||
:raises:
|
|
||||||
:exc:`QuorumError` in case of invalid data or if invariant after transition could not be satisfied
|
|
||||||
"""
|
|
||||||
if numsync < 0:
|
|
||||||
raise QuorumError(f'Sync {numsync} < 0 of ({sync})')
|
|
||||||
if numsync > len(sync):
|
|
||||||
raise QuorumError(f'Sync {numsync} > N of ({sync})')
|
|
||||||
|
|
||||||
self.numsync = numsync
|
|
||||||
self.sync = sync
|
|
||||||
self.check_invariants()
|
|
||||||
logger.debug('sync %s %s %s', self.leader, self.numsync, self.sync)
|
|
||||||
yield Transition('sync', self.leader, self.numsync, self.sync)
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[Transition]:
|
|
||||||
"""Iterate over the transitions produced by :meth:`_generate_transitions`.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
Merge two transitions of the same type to a single one.
|
|
||||||
|
|
||||||
This is always safe because skipping the first transition is equivalent
|
|
||||||
to no one observing the intermediate state.
|
|
||||||
|
|
||||||
:yields: transitions as :class:`Transition` objects.
|
|
||||||
"""
|
|
||||||
transitions = list(self._generate_transitions())
|
|
||||||
for cur_transition, next_transition in zip(transitions, transitions[1:] + [None]):
|
|
||||||
if isinstance(next_transition, Transition) \
|
|
||||||
and cur_transition.transition_type == next_transition.transition_type:
|
|
||||||
continue
|
|
||||||
yield cur_transition
|
|
||||||
if cur_transition.transition_type == 'restart':
|
|
||||||
break
|
|
||||||
|
|
||||||
def __handle_non_steady_cases(self) -> Iterator[Transition]:
|
|
||||||
"""Handle cases when set of transitions produced on previous run was interrupted.
|
|
||||||
|
|
||||||
:yields: transitions as :class:`Transition` objects.
|
|
||||||
"""
|
|
||||||
if self.sync < self.voters:
|
|
||||||
logger.debug("Case 1: synchronous_standby_names %s is a subset of DCS state %s", self.sync, self.voters)
|
|
||||||
# Case 1: voters is superset of sync nodes. In the middle of changing voters (quorum).
|
|
||||||
# Evict dead nodes from voters that are not being synced.
|
|
||||||
remove_from_voters = self.voters - (self.sync | self.active)
|
|
||||||
if remove_from_voters:
|
|
||||||
yield from self.quorum_update(
|
|
||||||
quorum=len(self.voters) - len(remove_from_voters) - self.numsync,
|
|
||||||
voters=CaseInsensitiveSet(self.voters - remove_from_voters),
|
|
||||||
adjust_quorum=not (self.sync - self.active))
|
|
||||||
# Start syncing to nodes that are in voters and alive
|
|
||||||
add_to_sync = (self.voters & self.active) - self.sync
|
|
||||||
if add_to_sync:
|
|
||||||
yield from self.sync_update(self.numsync, CaseInsensitiveSet(self.sync | add_to_sync))
|
|
||||||
elif self.sync > self.voters:
|
|
||||||
logger.debug("Case 2: synchronous_standby_names %s is a superset of DCS state %s", self.sync, self.voters)
|
|
||||||
# Case 2: sync is superset of voters nodes. In the middle of changing replication factor (sync).
|
|
||||||
# Add to voters nodes that are already synced and active
|
|
||||||
add_to_voters = (self.sync - self.voters) & self.active
|
|
||||||
if add_to_voters:
|
|
||||||
voters = CaseInsensitiveSet(self.voters | add_to_voters)
|
|
||||||
yield from self.quorum_update(len(voters) - self.numsync, voters)
|
|
||||||
# Remove from sync nodes that are dead
|
|
||||||
remove_from_sync = self.sync - self.voters
|
|
||||||
if remove_from_sync:
|
|
||||||
yield from self.sync_update(
|
|
||||||
numsync=min(self.numsync, len(self.sync) - len(remove_from_sync)),
|
|
||||||
sync=CaseInsensitiveSet(self.sync - remove_from_sync))
|
|
||||||
|
|
||||||
# After handling these two cases voters and sync must match.
|
|
||||||
assert self.voters == self.sync
|
|
||||||
|
|
||||||
safety_margin = self.quorum + min(self.numsync, self.numsync_confirmed) - len(self.voters | self.sync)
|
|
||||||
if safety_margin > 0: # In the middle of changing replication factor.
|
|
||||||
if self.numsync > self.sync_wanted:
|
|
||||||
numsync = max(self.sync_wanted, len(self.voters) - self.quorum)
|
|
||||||
logger.debug('Case 3: replication factor %d is bigger than needed %d', self.numsync, numsync)
|
|
||||||
yield from self.sync_update(numsync, self.sync)
|
|
||||||
else:
|
|
||||||
quorum = len(self.sync) - self.numsync
|
|
||||||
logger.debug('Case 4: quorum %d is bigger than needed %d', self.quorum, quorum)
|
|
||||||
yield from self.quorum_update(quorum, self.voters)
|
|
||||||
else:
|
|
||||||
safety_margin = self.quorum + self.numsync - len(self.voters | self.sync)
|
|
||||||
if self.numsync == self.sync_wanted and safety_margin > 0 and self.numsync > self.numsync_confirmed:
|
|
||||||
yield from self.quorum_update(len(self.sync) - self.numsync, self.voters)
|
|
||||||
|
|
||||||
def __remove_gone_nodes(self) -> Iterator[Transition]:
|
|
||||||
"""Remove inactive nodes from ``synchronous_standby_names`` and from ``/sync`` key.
|
|
||||||
|
|
||||||
:yields: transitions as :class:`Transition` objects.
|
|
||||||
"""
|
|
||||||
to_remove = self.sync - self.active
|
|
||||||
if to_remove and self.sync == to_remove:
|
|
||||||
logger.debug("Removing nodes: %s", to_remove)
|
|
||||||
yield from self.quorum_update(0, CaseInsensitiveSet(), adjust_quorum=False)
|
|
||||||
yield from self.sync_update(0, CaseInsensitiveSet())
|
|
||||||
elif to_remove:
|
|
||||||
logger.debug("Removing nodes: %s", to_remove)
|
|
||||||
can_reduce_quorum_by = self.quorum
|
|
||||||
# If we can reduce quorum size try to do so first
|
|
||||||
if can_reduce_quorum_by:
|
|
||||||
# Pick nodes to remove by sorted order to provide deterministic behavior for tests
|
|
||||||
remove = CaseInsensitiveSet(sorted(to_remove, reverse=True)[:can_reduce_quorum_by])
|
|
||||||
sync = CaseInsensitiveSet(self.sync - remove)
|
|
||||||
# when removing nodes from sync we can safely increase numsync if requested
|
|
||||||
numsync = min(self.sync_wanted, len(sync)) if self.sync_wanted > self.numsync else self.numsync
|
|
||||||
yield from self.sync_update(numsync, sync)
|
|
||||||
voters = CaseInsensitiveSet(self.voters - remove)
|
|
||||||
to_remove &= self.sync
|
|
||||||
yield from self.quorum_update(len(voters) - self.numsync, voters,
|
|
||||||
adjust_quorum=not to_remove)
|
|
||||||
if to_remove:
|
|
||||||
assert self.quorum == 0
|
|
||||||
numsync = self.numsync - len(to_remove)
|
|
||||||
sync = CaseInsensitiveSet(self.sync - to_remove)
|
|
||||||
voters = CaseInsensitiveSet(self.voters - to_remove)
|
|
||||||
sync_decrease = numsync - min(self.sync_wanted, len(sync))
|
|
||||||
quorum = min(sync_decrease, len(voters) - 1) if sync_decrease else 0
|
|
||||||
yield from self.quorum_update(quorum, voters, adjust_quorum=False)
|
|
||||||
yield from self.sync_update(numsync, sync)
|
|
||||||
|
|
||||||
def __add_new_nodes(self) -> Iterator[Transition]:
|
|
||||||
"""Add new active nodes to ``synchronous_standby_names`` and to ``/sync`` key.
|
|
||||||
|
|
||||||
:yields: transitions as :class:`Transition` objects.
|
|
||||||
"""
|
|
||||||
to_add = self.active - self.sync
|
|
||||||
if to_add:
|
|
||||||
# First get to requested replication factor
|
|
||||||
logger.debug("Adding nodes: %s", to_add)
|
|
||||||
sync_wanted = min(self.sync_wanted, len(self.sync | to_add))
|
|
||||||
increase_numsync_by = sync_wanted - self.numsync
|
|
||||||
if increase_numsync_by > 0:
|
|
||||||
if self.sync:
|
|
||||||
add = CaseInsensitiveSet(sorted(to_add)[:increase_numsync_by])
|
|
||||||
increase_numsync_by = len(add)
|
|
||||||
else: # there is only the leader
|
|
||||||
add = to_add # and it is safe to add all nodes at once if sync is empty
|
|
||||||
yield from self.sync_update(self.numsync + increase_numsync_by, CaseInsensitiveSet(self.sync | add))
|
|
||||||
voters = CaseInsensitiveSet(self.voters | add)
|
|
||||||
yield from self.quorum_update(len(voters) - sync_wanted, voters)
|
|
||||||
to_add -= self.sync
|
|
||||||
if to_add:
|
|
||||||
voters = CaseInsensitiveSet(self.voters | to_add)
|
|
||||||
yield from self.quorum_update(len(voters) - sync_wanted, voters,
|
|
||||||
adjust_quorum=sync_wanted > self.numsync_confirmed)
|
|
||||||
yield from self.sync_update(sync_wanted, CaseInsensitiveSet(self.sync | to_add))
|
|
||||||
|
|
||||||
def __handle_replication_factor_change(self) -> Iterator[Transition]:
|
|
||||||
"""Handle change of the replication factor (:attr:`sync_wanted`, aka ``synchronous_node_count``).
|
|
||||||
|
|
||||||
:yields: transitions as :class:`Transition` objects.
|
|
||||||
"""
|
|
||||||
# Apply requested replication factor change
|
|
||||||
sync_increase = min(self.sync_wanted, len(self.sync)) - self.numsync
|
|
||||||
if sync_increase > 0:
|
|
||||||
# Increase replication factor
|
|
||||||
logger.debug("Increasing replication factor to %s", self.numsync + sync_increase)
|
|
||||||
yield from self.sync_update(self.numsync + sync_increase, self.sync)
|
|
||||||
yield from self.quorum_update(len(self.voters) - self.numsync, self.voters)
|
|
||||||
elif sync_increase < 0:
|
|
||||||
# Reduce replication factor
|
|
||||||
logger.debug("Reducing replication factor to %s", self.numsync + sync_increase)
|
|
||||||
if self.quorum - sync_increase < len(self.voters):
|
|
||||||
yield from self.quorum_update(len(self.voters) - self.numsync - sync_increase, self.voters,
|
|
||||||
adjust_quorum=self.sync_wanted > self.numsync_confirmed)
|
|
||||||
yield from self.sync_update(self.numsync + sync_increase, self.sync)
|
|
||||||
|
|
||||||
def _generate_transitions(self) -> Iterator[Transition]:
|
|
||||||
"""Produce a set of changes to safely transition from the current state to the desired.
|
|
||||||
|
|
||||||
:yields: transitions as :class:`Transition` objects.
|
|
||||||
"""
|
|
||||||
logger.debug("Quorum state: leader %s quorum %s, voters %s, numsync %s, sync %s, "
|
|
||||||
"numsync_confirmed %s, active %s, sync_wanted %s leader_wanted %s",
|
|
||||||
self.leader, self.quorum, self.voters, self.numsync, self.sync,
|
|
||||||
self.numsync_confirmed, self.active, self.sync_wanted, self.leader_wanted)
|
|
||||||
try:
|
|
||||||
if self.leader_wanted != self.leader: # failover
|
|
||||||
voters = (self.voters - CaseInsensitiveSet([self.leader_wanted])) | CaseInsensitiveSet([self.leader])
|
|
||||||
if not self.sync:
|
|
||||||
# If sync is empty we need to update synchronous_standby_names first
|
|
||||||
numsync = len(voters) - self.quorum
|
|
||||||
yield from self.sync_update(numsync, CaseInsensitiveSet(voters))
|
|
||||||
# If leader changed we need to add the old leader to quorum (voters)
|
|
||||||
yield from self.quorum_update(self.quorum, CaseInsensitiveSet(voters), self.leader_wanted)
|
|
||||||
# right after promote there could be no replication connections yet
|
|
||||||
if not self.sync & self.active:
|
|
||||||
return # give another loop_wait seconds for replicas to reconnect before removing them from quorum
|
|
||||||
else:
|
|
||||||
self.check_invariants()
|
|
||||||
except QuorumError as e:
|
|
||||||
logger.warning('%s', e)
|
|
||||||
yield from self.quorum_update(len(self.sync) - self.numsync, self.sync)
|
|
||||||
|
|
||||||
assert self.leader == self.leader_wanted
|
|
||||||
|
|
||||||
# numsync_confirmed could be 0 after restart/failover, we will calculate it from quorum
|
|
||||||
if self.numsync_confirmed == 0 and self.sync & self.active:
|
|
||||||
self.numsync_confirmed = min(len(self.sync & self.active), len(self.voters) - self.quorum)
|
|
||||||
logger.debug('numsync_confirmed=0, adjusting it to %d', self.numsync_confirmed)
|
|
||||||
|
|
||||||
yield from self.__handle_non_steady_cases()
|
|
||||||
|
|
||||||
# We are in a steady state point. Find if desired state is different and act accordingly.
|
|
||||||
|
|
||||||
yield from self.__remove_gone_nodes()
|
|
||||||
|
|
||||||
yield from self.__add_new_nodes()
|
|
||||||
|
|
||||||
yield from self.__handle_replication_factor_change()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Create :mod:`patroni.scripts.barman`."""
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
"""Perform operations on Barman through ``pg-backup-api``.
|
|
||||||
|
|
||||||
The actual operations are implemented by separate modules. This module only
|
|
||||||
builds the CLI that makes an interface with the actual commands.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
See :class:ExitCode` for possible exit codes of this main script.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from argparse import ArgumentParser
|
|
||||||
from enum import IntEnum
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .config_switch import run_barman_config_switch
|
|
||||||
from .recover import run_barman_recover
|
|
||||||
from .utils import ApiNotOk, PgBackupApi, set_up_logging
|
|
||||||
|
|
||||||
|
|
||||||
class ExitCode(IntEnum):
|
|
||||||
"""Possible exit codes of this script.
|
|
||||||
|
|
||||||
:cvar NO_COMMAND: if no sub-command of ``patroni_barman`` application has
|
|
||||||
been selected by the user.
|
|
||||||
:cvar API_NOT_OK: ``pg-backup-api`` status is not ``OK``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
NO_COMMAND = -1
|
|
||||||
API_NOT_OK = -2
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Entry point of ``patroni_barman`` application.
|
|
||||||
|
|
||||||
Implements the parser for the application and for its sub-commands.
|
|
||||||
|
|
||||||
The script exit code may be one of:
|
|
||||||
|
|
||||||
* :attr:`ExitCode.NO_COMMAND`: if no sub-command was specified in the
|
|
||||||
``patroni_barman`` call;
|
|
||||||
* :attr:`ExitCode.API_NOT_OK`: if ``pg-backup-api`` is not correctly up and
|
|
||||||
running;
|
|
||||||
* Value returned by :func:`~patroni.scripts.barman.config_switch.run_barman_config_switch`,
|
|
||||||
if running ``patroni_barman config-switch``;
|
|
||||||
* Value returned by :func:`~patroni.scripts.barman.recover.run_barman_recover`,
|
|
||||||
if running ``patroni_barman recover``.
|
|
||||||
|
|
||||||
The called sub-command is expected to exit execution once finished using
|
|
||||||
its own set of exit codes.
|
|
||||||
"""
|
|
||||||
parser = ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"Wrapper application for pg-backup-api. Communicate with the API "
|
|
||||||
"running at the given URL to perform remote Barman operations."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--api-url",
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
help="URL to reach the pg-backup-api, e.g. 'http://localhost:7480'",
|
|
||||||
dest="api_url",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--cert-file",
|
|
||||||
type=str,
|
|
||||||
required=False,
|
|
||||||
help="Certificate to authenticate against the API, if required.",
|
|
||||||
dest="cert_file",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--key-file",
|
|
||||||
type=str,
|
|
||||||
required=False,
|
|
||||||
help="Certificate key to authenticate against the API, if required.",
|
|
||||||
dest="key_file",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--retry-wait",
|
|
||||||
type=int,
|
|
||||||
required=False,
|
|
||||||
default=2,
|
|
||||||
help="How long in seconds to wait before retrying a failed "
|
|
||||||
"pg-backup-api request (default: '%(default)s')",
|
|
||||||
dest="retry_wait",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-retries",
|
|
||||||
type=int,
|
|
||||||
required=False,
|
|
||||||
default=5,
|
|
||||||
help="Maximum number of retries when receiving malformed responses "
|
|
||||||
"from the pg-backup-api (default: '%(default)s')",
|
|
||||||
dest="max_retries",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--log-file",
|
|
||||||
type=str,
|
|
||||||
required=False,
|
|
||||||
help="File where to log messages produced by this application, if any.",
|
|
||||||
dest="log_file",
|
|
||||||
)
|
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(title="Sub-commands")
|
|
||||||
|
|
||||||
recover_parser = subparsers.add_parser(
|
|
||||||
"recover",
|
|
||||||
help="Remote 'barman recover'",
|
|
||||||
description="Restore a Barman backup of a given Barman server"
|
|
||||||
)
|
|
||||||
recover_parser.add_argument(
|
|
||||||
"--barman-server",
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
help="Name of the Barman server from which to restore the backup.",
|
|
||||||
dest="barman_server",
|
|
||||||
)
|
|
||||||
recover_parser.add_argument(
|
|
||||||
"--backup-id",
|
|
||||||
type=str,
|
|
||||||
required=False,
|
|
||||||
default="latest",
|
|
||||||
help="ID of the Barman backup to be restored. You can use any value "
|
|
||||||
"supported by 'barman recover' command "
|
|
||||||
"(default: '%(default)s')",
|
|
||||||
dest="backup_id",
|
|
||||||
)
|
|
||||||
recover_parser.add_argument(
|
|
||||||
"--ssh-command",
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
help="Value to be passed as '--remote-ssh-command' to 'barman recover'.",
|
|
||||||
dest="ssh_command",
|
|
||||||
)
|
|
||||||
recover_parser.add_argument(
|
|
||||||
"--data-directory",
|
|
||||||
"--datadir",
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
help="Destination path where to restore the barman backup in the "
|
|
||||||
"local host.",
|
|
||||||
dest="data_directory",
|
|
||||||
)
|
|
||||||
recover_parser.add_argument(
|
|
||||||
"--loop-wait",
|
|
||||||
type=int,
|
|
||||||
required=False,
|
|
||||||
default=10,
|
|
||||||
help="How long to wait before checking again the status of the "
|
|
||||||
"recovery process, in seconds. Use higher values if your "
|
|
||||||
"recovery is expected to take long (default: '%(default)s')",
|
|
||||||
dest="loop_wait",
|
|
||||||
)
|
|
||||||
recover_parser.set_defaults(func=run_barman_recover)
|
|
||||||
|
|
||||||
config_switch_parser = subparsers.add_parser(
|
|
||||||
"config-switch",
|
|
||||||
help="Remote 'barman config-switch'",
|
|
||||||
description="Switch the configuration of a given Barman server. "
|
|
||||||
"Intended to be used as a 'on_role_change' callback."
|
|
||||||
)
|
|
||||||
config_switch_parser.add_argument(
|
|
||||||
"action",
|
|
||||||
type=str,
|
|
||||||
choices=["on_role_change"],
|
|
||||||
help="Name of the callback (automatically filled by Patroni)",
|
|
||||||
)
|
|
||||||
config_switch_parser.add_argument(
|
|
||||||
"role",
|
|
||||||
type=str,
|
|
||||||
choices=["master", "primary", "promoted", "standby_leader", "replica",
|
|
||||||
"demoted"],
|
|
||||||
help="Name of the new role of this node (automatically filled by "
|
|
||||||
"Patroni)",
|
|
||||||
)
|
|
||||||
config_switch_parser.add_argument(
|
|
||||||
"cluster",
|
|
||||||
type=str,
|
|
||||||
help="Name of the Patroni cluster involved in the callback "
|
|
||||||
"(automatically filled by Patroni)",
|
|
||||||
)
|
|
||||||
config_switch_parser.add_argument(
|
|
||||||
"--barman-server",
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
help="Name of the Barman server which config is to be switched.",
|
|
||||||
dest="barman_server",
|
|
||||||
)
|
|
||||||
group = config_switch_parser.add_mutually_exclusive_group(required=True)
|
|
||||||
group.add_argument(
|
|
||||||
"--barman-model",
|
|
||||||
type=str,
|
|
||||||
help="Name of the Barman config model to be applied to the server.",
|
|
||||||
dest="barman_model",
|
|
||||||
)
|
|
||||||
group.add_argument(
|
|
||||||
"--reset",
|
|
||||||
action="store_true",
|
|
||||||
help="Unapply the currently active model for the server, if any.",
|
|
||||||
dest="reset",
|
|
||||||
)
|
|
||||||
config_switch_parser.add_argument(
|
|
||||||
"--switch-when",
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
default="promoted",
|
|
||||||
choices=["promoted", "demoted", "always"],
|
|
||||||
help="Controls under which circumstances the 'on_role_change' callback "
|
|
||||||
"should actually switch config in Barman. 'promoted' means the "
|
|
||||||
"'role' is either 'master', 'primary' or 'promoted'. 'demoted' "
|
|
||||||
"means the 'role' is either 'replica' or 'demoted' "
|
|
||||||
"(default: '%(default)s')",
|
|
||||||
dest="switch_when",
|
|
||||||
)
|
|
||||||
config_switch_parser.set_defaults(func=run_barman_config_switch)
|
|
||||||
|
|
||||||
args, _ = parser.parse_known_args()
|
|
||||||
|
|
||||||
set_up_logging(args.log_file)
|
|
||||||
|
|
||||||
if not hasattr(args, "func"):
|
|
||||||
parser.print_help()
|
|
||||||
sys.exit(ExitCode.NO_COMMAND)
|
|
||||||
|
|
||||||
api = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
api = PgBackupApi(args.api_url, args.cert_file, args.key_file,
|
|
||||||
args.retry_wait, args.max_retries)
|
|
||||||
except ApiNotOk as exc:
|
|
||||||
logging.error("pg-backup-api is not working: %r", exc)
|
|
||||||
sys.exit(ExitCode.API_NOT_OK)
|
|
||||||
|
|
||||||
sys.exit(args.func(api, args))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
"""Implements ``patroni_barman config-switch`` sub-command.
|
|
||||||
|
|
||||||
Apply a Barman configuration model through ``pg-backup-api``.
|
|
||||||
|
|
||||||
This sub-command is specially useful as a ``on_role_change`` callback to change
|
|
||||||
Barman configuration in response to failovers and switchovers. Check the output
|
|
||||||
of ``--help`` to understand the parameters supported by the sub-command.
|
|
||||||
|
|
||||||
It requires that you have previously configured a Barman server and Barman
|
|
||||||
config models, and that you have ``pg-backup-api`` configured and running in
|
|
||||||
the same host as Barman.
|
|
||||||
|
|
||||||
Refer to :class:`ExitCode` for possible exit codes of this sub-command.
|
|
||||||
"""
|
|
||||||
from argparse import Namespace
|
|
||||||
from enum import IntEnum
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Optional, TYPE_CHECKING
|
|
||||||
|
|
||||||
from .utils import OperationStatus, RetriesExceeded
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
|
||||||
from .utils import PgBackupApi
|
|
||||||
|
|
||||||
|
|
||||||
class ExitCode(IntEnum):
|
|
||||||
"""Possible exit codes of this script.
|
|
||||||
|
|
||||||
:cvar CONFIG_SWITCH_DONE: config switch was successfully performed.
|
|
||||||
:cvar CONFIG_SWITCH_SKIPPED: if the execution was skipped because of not
|
|
||||||
matching user expectations.
|
|
||||||
:cvar CONFIG_SWITCH_FAILED: config switch faced an issue.
|
|
||||||
:cvar HTTP_ERROR: an error has occurred while communicating with
|
|
||||||
``pg-backup-api``
|
|
||||||
:cvar INVALID_ARGS: an invalid set of arguments has been given to the
|
|
||||||
operation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
CONFIG_SWITCH_DONE = 0
|
|
||||||
CONFIG_SWITCH_SKIPPED = 1
|
|
||||||
CONFIG_SWITCH_FAILED = 2
|
|
||||||
HTTP_ERROR = 3
|
|
||||||
INVALID_ARGS = 4
|
|
||||||
|
|
||||||
|
|
||||||
def _should_skip_switch(args: Namespace) -> bool:
|
|
||||||
"""Check if we should skip the config switch operation.
|
|
||||||
|
|
||||||
:param args: arguments received from the command-line of
|
|
||||||
``patroni_barman config-switch`` command.
|
|
||||||
|
|
||||||
:returns: if the operation should be skipped.
|
|
||||||
"""
|
|
||||||
if args.switch_when == "promoted":
|
|
||||||
return args.role not in {"master", "primary", "promoted"}
|
|
||||||
if args.switch_when == "demoted":
|
|
||||||
return args.role not in {"replica", "demoted"}
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _switch_config(api: "PgBackupApi", barman_server: str,
|
|
||||||
barman_model: Optional[str], reset: Optional[bool]) -> int:
|
|
||||||
"""Switch configuration of Barman server through ``pg-backup-api``.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
If requests to ``pg-backup-api`` fail recurrently or we face HTTP
|
|
||||||
errors, then exit with :attr:`ExitCode.HTTP_ERROR`.
|
|
||||||
|
|
||||||
:param api: a :class:`PgBackupApi` instance to handle communication with
|
|
||||||
the API.
|
|
||||||
:param barman_server: name of the Barman server which config is to be
|
|
||||||
switched.
|
|
||||||
:param barman_model: name of the Barman model to be applied to the server,
|
|
||||||
if any.
|
|
||||||
:param reset: ``True`` if you would like to unapply the currently active
|
|
||||||
model for the server, if any.
|
|
||||||
|
|
||||||
:returns: the return code to be used when exiting the ``patroni_barman``
|
|
||||||
application. Refer to :class:`ExitCode`.
|
|
||||||
"""
|
|
||||||
operation_id = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
operation_id = api.create_config_switch_operation(
|
|
||||||
barman_server,
|
|
||||||
barman_model,
|
|
||||||
reset,
|
|
||||||
)
|
|
||||||
except RetriesExceeded as exc:
|
|
||||||
logging.error("An issue was faced while trying to create a config "
|
|
||||||
"switch operation: %r", exc)
|
|
||||||
return ExitCode.HTTP_ERROR
|
|
||||||
|
|
||||||
logging.info("Created the config switch operation with ID %s",
|
|
||||||
operation_id)
|
|
||||||
|
|
||||||
status = None
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
status = api.get_operation_status(barman_server, operation_id)
|
|
||||||
except RetriesExceeded:
|
|
||||||
logging.error("Maximum number of retries exceeded, exiting.")
|
|
||||||
return ExitCode.HTTP_ERROR
|
|
||||||
|
|
||||||
if status != OperationStatus.IN_PROGRESS:
|
|
||||||
break
|
|
||||||
|
|
||||||
logging.info("Config switch operation %s is still in progress",
|
|
||||||
operation_id)
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
if status == OperationStatus.DONE:
|
|
||||||
logging.info("Config switch operation finished successfully.")
|
|
||||||
return ExitCode.CONFIG_SWITCH_DONE
|
|
||||||
else:
|
|
||||||
logging.error("Config switch operation failed.")
|
|
||||||
return ExitCode.CONFIG_SWITCH_FAILED
|
|
||||||
|
|
||||||
|
|
||||||
def run_barman_config_switch(api: "PgBackupApi", args: Namespace) -> int:
|
|
||||||
"""Run a remote ``barman config-switch`` through the ``pg-backup-api``.
|
|
||||||
|
|
||||||
:param api: a :class:`PgBackupApi` instance to handle communication with
|
|
||||||
the API.
|
|
||||||
:param args: arguments received from the command-line of
|
|
||||||
``patroni_barman config-switch`` command.
|
|
||||||
|
|
||||||
:returns: the return code to be used when exiting the ``patroni_barman``
|
|
||||||
application. Refer to :class:`ExitCode`.
|
|
||||||
"""
|
|
||||||
if _should_skip_switch(args):
|
|
||||||
logging.info("Config switch operation was skipped (role=%s, "
|
|
||||||
"switch_when=%s).", args.role, args.switch_when)
|
|
||||||
return ExitCode.CONFIG_SWITCH_SKIPPED
|
|
||||||
|
|
||||||
if not bool(args.barman_model) ^ bool(args.reset):
|
|
||||||
logging.error("One, and only one among 'barman_model' ('%s') and "
|
|
||||||
"'reset' ('%s') should be given", args.barman_model, args.reset)
|
|
||||||
return ExitCode.INVALID_ARGS
|
|
||||||
|
|
||||||
return _switch_config(api, args.barman_server, args.barman_model, args.reset)
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
"""Implements ``patroni_barman recover`` sub-command.
|
|
||||||
|
|
||||||
Restore a Barman backup to the local node through ``pg-backup-api``.
|
|
||||||
|
|
||||||
This sub-command can be used both as a custom bootstrap method, and as a custom
|
|
||||||
create replica method. Check the output of ``--help`` to understand the
|
|
||||||
parameters supported by the sub-command. ``--datadir`` is a special parameter
|
|
||||||
and it is automatically filled by Patroni in both cases.
|
|
||||||
|
|
||||||
It requires that you have previously configured a Barman server, and that you
|
|
||||||
have ``pg-backup-api`` configured and running in the same host as Barman.
|
|
||||||
|
|
||||||
Refer to :class:`ExitCode` for possible exit codes of this sub-command.
|
|
||||||
"""
|
|
||||||
from argparse import Namespace
|
|
||||||
from enum import IntEnum
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from .utils import OperationStatus, RetriesExceeded
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
|
||||||
from .utils import PgBackupApi
|
|
||||||
|
|
||||||
|
|
||||||
class ExitCode(IntEnum):
|
|
||||||
"""Possible exit codes of this script.
|
|
||||||
|
|
||||||
:cvar RECOVERY_DONE: backup was successfully restored.
|
|
||||||
:cvar RECOVERY_FAILED: recovery of the backup faced an issue.
|
|
||||||
:cvar HTTP_ERROR: an error has occurred while communicating with
|
|
||||||
``pg-backup-api``
|
|
||||||
"""
|
|
||||||
|
|
||||||
RECOVERY_DONE = 0
|
|
||||||
RECOVERY_FAILED = 1
|
|
||||||
HTTP_ERROR = 2
|
|
||||||
|
|
||||||
|
|
||||||
def _restore_backup(api: "PgBackupApi", barman_server: str, backup_id: str,
|
|
||||||
ssh_command: str, data_directory: str,
|
|
||||||
loop_wait: int) -> int:
|
|
||||||
"""Restore the configured Barman backup through ``pg-backup-api``.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
If requests to ``pg-backup-api`` fail recurrently or we face HTTP
|
|
||||||
errors, then exit with :attr:`ExitCode.HTTP_ERROR`.
|
|
||||||
|
|
||||||
:param api: a :class:`PgBackupApi` instance to handle communication with
|
|
||||||
the API.
|
|
||||||
:param barman_server: name of the Barman server which backup is to be
|
|
||||||
restored.
|
|
||||||
:param backup_id: ID of the backup from the Barman server.
|
|
||||||
:param ssh_command: SSH command to connect from the Barman host to the
|
|
||||||
target host.
|
|
||||||
:param data_directory: path to the Postgres data directory where to restore
|
|
||||||
the backup in.
|
|
||||||
:param loop_wait: how long in seconds to wait before checking again the
|
|
||||||
status of the recovery process. Higher values are useful for backups
|
|
||||||
that are expected to take longer to restore.
|
|
||||||
|
|
||||||
:returns: the return code to be used when exiting the ``patroni_barman``
|
|
||||||
application. Refer to :class:`ExitCode`.
|
|
||||||
"""
|
|
||||||
operation_id = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
operation_id = api.create_recovery_operation(
|
|
||||||
barman_server,
|
|
||||||
backup_id,
|
|
||||||
ssh_command,
|
|
||||||
data_directory,
|
|
||||||
)
|
|
||||||
except RetriesExceeded as exc:
|
|
||||||
logging.error("An issue was faced while trying to create a recovery "
|
|
||||||
"operation: %r", exc)
|
|
||||||
return ExitCode.HTTP_ERROR
|
|
||||||
|
|
||||||
logging.info("Created the recovery operation with ID %s", operation_id)
|
|
||||||
|
|
||||||
status = None
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
status = api.get_operation_status(barman_server, operation_id)
|
|
||||||
except RetriesExceeded:
|
|
||||||
logging.error("Maximum number of retries exceeded, exiting.")
|
|
||||||
return ExitCode.HTTP_ERROR
|
|
||||||
|
|
||||||
if status != OperationStatus.IN_PROGRESS:
|
|
||||||
break
|
|
||||||
|
|
||||||
logging.info("Recovery operation %s is still in progress",
|
|
||||||
operation_id)
|
|
||||||
time.sleep(loop_wait)
|
|
||||||
|
|
||||||
if status == OperationStatus.DONE:
|
|
||||||
logging.info("Recovery operation finished successfully.")
|
|
||||||
return ExitCode.RECOVERY_DONE
|
|
||||||
else:
|
|
||||||
logging.error("Recovery operation failed.")
|
|
||||||
return ExitCode.RECOVERY_FAILED
|
|
||||||
|
|
||||||
|
|
||||||
def run_barman_recover(api: "PgBackupApi", args: Namespace) -> int:
|
|
||||||
"""Run a remote ``barman recover`` through the ``pg-backup-api``.
|
|
||||||
|
|
||||||
:param api: a :class:`PgBackupApi` instance to handle communication with
|
|
||||||
the API.
|
|
||||||
:param args: arguments received from the command-line of
|
|
||||||
``patroni_barman recover`` command.
|
|
||||||
|
|
||||||
:returns: the return code to be used when exiting the ``patroni_barman``
|
|
||||||
application. Refer to :class:`ExitCode`.
|
|
||||||
"""
|
|
||||||
return _restore_backup(api, args.barman_server, args.backup_id,
|
|
||||||
args.ssh_command, args.data_directory,
|
|
||||||
args.loop_wait)
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
"""Utilitary stuff to be used by Barman related scripts."""
|
|
||||||
|
|
||||||
from enum import IntEnum
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union
|
|
||||||
import time
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from urllib3 import PoolManager
|
|
||||||
from urllib3.exceptions import MaxRetryError
|
|
||||||
from urllib3.response import HTTPResponse
|
|
||||||
|
|
||||||
|
|
||||||
class RetriesExceeded(Exception):
|
|
||||||
"""Maximum number of retries exceeded."""
|
|
||||||
|
|
||||||
|
|
||||||
def retry(exceptions: Union[Type[Exception], Tuple[Type[Exception], ...]]) \
|
|
||||||
-> Any:
|
|
||||||
"""Retry an operation n times if expected *exceptions* are faced.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
Should be used as a decorator of a class' method as it expects the
|
|
||||||
first argument to be a class instance.
|
|
||||||
|
|
||||||
The class which method is going to be decorated should contain a couple
|
|
||||||
attributes:
|
|
||||||
|
|
||||||
* ``max_retries``: maximum retry attempts before failing;
|
|
||||||
* ``retry_wait``: how long in seconds to wait before retrying.
|
|
||||||
|
|
||||||
:param exceptions: exceptions that could trigger a retry attempt.
|
|
||||||
|
|
||||||
:raises:
|
|
||||||
:exc:`RetriesExceeded`: if the maximum number of attempts has been
|
|
||||||
exhausted.
|
|
||||||
"""
|
|
||||||
def decorator(func: Callable[..., Any]) -> Any:
|
|
||||||
def inner_func(instance: object, *args: Any, **kwargs: Any) -> Any:
|
|
||||||
times: int = getattr(instance, "max_retries")
|
|
||||||
retry_wait: int = getattr(instance, "retry_wait")
|
|
||||||
method_name = f"{instance.__class__.__name__}.{func.__name__}"
|
|
||||||
|
|
||||||
attempt = 1
|
|
||||||
|
|
||||||
while attempt <= times:
|
|
||||||
try:
|
|
||||||
return func(instance, *args, **kwargs)
|
|
||||||
except exceptions as exc:
|
|
||||||
logging.warning("Attempt %d of %d on method %s failed "
|
|
||||||
"with %r.",
|
|
||||||
attempt, times, method_name, exc)
|
|
||||||
attempt += 1
|
|
||||||
|
|
||||||
time.sleep(retry_wait)
|
|
||||||
|
|
||||||
raise RetriesExceeded("Maximum number of retries exceeded for "
|
|
||||||
f"method {method_name}.")
|
|
||||||
return inner_func
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def set_up_logging(log_file: Optional[str] = None) -> None:
|
|
||||||
"""Set up logging to file, if *log_file* is given, otherwise to console.
|
|
||||||
|
|
||||||
:param log_file: file where to log messages, if any.
|
|
||||||
"""
|
|
||||||
logging.basicConfig(filename=log_file, level=logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)s: %(message)s")
|
|
||||||
|
|
||||||
|
|
||||||
class OperationStatus(IntEnum):
|
|
||||||
"""Possible status of ``pg-backup-api`` operations.
|
|
||||||
|
|
||||||
:cvar IN_PROGRESS: the operation is still ongoing.
|
|
||||||
:cvar FAILED: the operation failed.
|
|
||||||
:cvar DONE: the operation finished successfully.
|
|
||||||
"""
|
|
||||||
|
|
||||||
IN_PROGRESS = 0
|
|
||||||
FAILED = 1
|
|
||||||
DONE = 2
|
|
||||||
|
|
||||||
|
|
||||||
class ApiNotOk(Exception):
|
|
||||||
"""The ``pg-backup-api`` is not currently up and running."""
|
|
||||||
|
|
||||||
|
|
||||||
class PgBackupApi:
|
|
||||||
"""Facilities for communicating with the ``pg-backup-api``.
|
|
||||||
|
|
||||||
:ivar api_url: base URL to reach the ``pg-backup-api``.
|
|
||||||
:ivar cert_file: certificate to authenticate against the ``pg-backup-api``,
|
|
||||||
if required.
|
|
||||||
:ivar key_file: certificate key to authenticate against the
|
|
||||||
``pg-backup-api``, if required.
|
|
||||||
:ivar retry_wait: how long in seconds to wait before retrying a failed
|
|
||||||
request to the ``pg-backup-api``.
|
|
||||||
:ivar max_retries: maximum number of retries when ``pg-backup-api`` returns
|
|
||||||
malformed responses.
|
|
||||||
:ivar http: a HTTP pool manager for performing web requests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, api_url: str, cert_file: Optional[str],
|
|
||||||
key_file: Optional[str], retry_wait: int,
|
|
||||||
max_retries: int) -> None:
|
|
||||||
"""Create a new instance of :class:`BarmanRecover`.
|
|
||||||
|
|
||||||
Make sure the ``pg-backup-api`` is reachable and running fine.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
When using any method which send requests to the API, be aware that
|
|
||||||
they might raise :exc:`RetriesExceeded` upon HTTP request errors.
|
|
||||||
|
|
||||||
Similarly, when instantiating this class you may face an
|
|
||||||
:exc:`ApiNotOk`, if the API is down or returns a bogus status.
|
|
||||||
|
|
||||||
:param api_url: base URL to reach the ``pg-backup-api``.
|
|
||||||
:param cert_file: certificate to authenticate against the
|
|
||||||
``pg-backup-api``, if required.
|
|
||||||
:param key_file: certificate key to authenticate against the
|
|
||||||
``pg-backup-api``, if required.
|
|
||||||
:param retry_wait: how long in seconds to wait before retrying a failed
|
|
||||||
request to the ``pg-backup-api``.
|
|
||||||
:param max_retries: maximum number of retries when ``pg-backup-api``
|
|
||||||
returns malformed responses.
|
|
||||||
"""
|
|
||||||
self.api_url = api_url
|
|
||||||
self.cert_file = cert_file
|
|
||||||
self.key_file = key_file
|
|
||||||
self.retry_wait = retry_wait
|
|
||||||
self.max_retries = max_retries
|
|
||||||
self._http = PoolManager(cert_file=cert_file, key_file=key_file)
|
|
||||||
self._ensure_api_ok()
|
|
||||||
|
|
||||||
def _build_full_url(self, url_path: str) -> str:
|
|
||||||
"""Build the full URL by concatenating *url_path* with the base URL.
|
|
||||||
|
|
||||||
:param url_path: path to be accessed in the ``pg-backup-api``.
|
|
||||||
|
|
||||||
:returns: the full URL after concatenating.
|
|
||||||
"""
|
|
||||||
return urljoin(self.api_url, url_path)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _deserialize_response(response: HTTPResponse) -> Any:
|
|
||||||
"""Retrieve body from *response* as a deserialized JSON object.
|
|
||||||
|
|
||||||
:param response: response from which JSON body will be deserialized.
|
|
||||||
|
|
||||||
:returns: the deserialized JSON body.
|
|
||||||
"""
|
|
||||||
return json.loads(response.data.decode("utf-8"))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _serialize_request(body: Any) -> Any:
|
|
||||||
"""Serialize a request body.
|
|
||||||
|
|
||||||
:param body: content of the request body to be serialized.
|
|
||||||
|
|
||||||
:returns: the serialized request body.
|
|
||||||
"""
|
|
||||||
return json.dumps(body).encode("utf-8")
|
|
||||||
|
|
||||||
def _get_request(self, url_path: str) -> Any:
|
|
||||||
"""Perform a ``GET`` request to *url_path*.
|
|
||||||
|
|
||||||
:param url_path: URL to perform the ``GET`` request against.
|
|
||||||
|
|
||||||
:returns: the deserialized response body.
|
|
||||||
|
|
||||||
:raises:
|
|
||||||
:exc:`RetriesExceeded`: raised from the corresponding :mod:`urllib3`
|
|
||||||
exception.
|
|
||||||
"""
|
|
||||||
url = self._build_full_url(url_path)
|
|
||||||
response = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self._http.request("GET", url)
|
|
||||||
except MaxRetryError as exc:
|
|
||||||
msg = f"Failed to perform a GET request to {url}"
|
|
||||||
raise RetriesExceeded(msg) from exc
|
|
||||||
|
|
||||||
return self._deserialize_response(response)
|
|
||||||
|
|
||||||
def _post_request(self, url_path: str, body: Any) -> Any:
|
|
||||||
"""Perform a ``POST`` request to *url_path* serializing *body* as JSON.
|
|
||||||
|
|
||||||
:param url_path: URL to perform the ``POST`` request against.
|
|
||||||
:param body: the body to be serialized as JSON and sent in the request.
|
|
||||||
|
|
||||||
:returns: the deserialized response body.
|
|
||||||
|
|
||||||
:raises:
|
|
||||||
:exc:`RetriesExceeded`: raised from the corresponding :mod:`urllib3`
|
|
||||||
exception.
|
|
||||||
"""
|
|
||||||
body = self._serialize_request(body)
|
|
||||||
|
|
||||||
url = self._build_full_url(url_path)
|
|
||||||
response = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self._http.request("POST",
|
|
||||||
url,
|
|
||||||
body=body,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
})
|
|
||||||
except MaxRetryError as exc:
|
|
||||||
msg = f"Failed to perform a POST request to {url} with {body}"
|
|
||||||
raise RetriesExceeded(msg) from exc
|
|
||||||
|
|
||||||
return self._deserialize_response(response)
|
|
||||||
|
|
||||||
def _ensure_api_ok(self) -> None:
|
|
||||||
"""Ensure ``pg-backup-api`` is reachable and ``OK``.
|
|
||||||
|
|
||||||
:raises:
|
|
||||||
:exc:`ApiNotOk`: if ``pg-backup-api`` status is not ``OK``.
|
|
||||||
"""
|
|
||||||
response = self._get_request("status")
|
|
||||||
|
|
||||||
if response != "OK":
|
|
||||||
msg = (
|
|
||||||
"pg-backup-api is currently not up and running at "
|
|
||||||
f"{self.api_url}: {response}"
|
|
||||||
)
|
|
||||||
|
|
||||||
raise ApiNotOk(msg)
|
|
||||||
|
|
||||||
@retry(KeyError)
|
|
||||||
def get_operation_status(self, barman_server: str,
|
|
||||||
operation_id: str) -> OperationStatus:
|
|
||||||
"""Get status of the operation which ID is *operation_id*.
|
|
||||||
|
|
||||||
:param barman_server: name of the Barman server related with the
|
|
||||||
operation.
|
|
||||||
:param operation_id: ID of the operation to be checked.
|
|
||||||
|
|
||||||
:returns: the status of the operation.
|
|
||||||
"""
|
|
||||||
response = self._get_request(
|
|
||||||
f"servers/{barman_server}/operations/{operation_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
status = response["status"]
|
|
||||||
return OperationStatus[status]
|
|
||||||
|
|
||||||
@retry(KeyError)
|
|
||||||
def create_recovery_operation(self, barman_server: str, backup_id: str,
|
|
||||||
ssh_command: str, data_directory: str) -> str:
|
|
||||||
"""Create a recovery operation on the ``pg-backup-api``.
|
|
||||||
|
|
||||||
:param barman_server: name of the Barman server which backup is to be
|
|
||||||
restored.
|
|
||||||
:param backup_id: ID of the backup from the Barman server.
|
|
||||||
:param ssh_command: SSH command to connect from the Barman host to the
|
|
||||||
target host.
|
|
||||||
:param data_directory: path to the Postgres data directory where to
|
|
||||||
restore the backup at.
|
|
||||||
|
|
||||||
:returns: the ID of the recovery operation that has been created.
|
|
||||||
"""
|
|
||||||
response = self._post_request(
|
|
||||||
f"servers/{barman_server}/operations",
|
|
||||||
{
|
|
||||||
"type": "recovery",
|
|
||||||
"backup_id": backup_id,
|
|
||||||
"remote_ssh_command": ssh_command,
|
|
||||||
"destination_directory": data_directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return response["operation_id"]
|
|
||||||
|
|
||||||
@retry(KeyError)
|
|
||||||
def create_config_switch_operation(self, barman_server: str,
|
|
||||||
barman_model: Optional[str],
|
|
||||||
reset: Optional[bool]) -> str:
|
|
||||||
"""Create a config switch operation on the ``pg-backup-api``.
|
|
||||||
|
|
||||||
:param barman_server: name of the Barman server which config is to be
|
|
||||||
switched.
|
|
||||||
:param barman_model: name of the Barman model to be applied to the
|
|
||||||
server, if any.
|
|
||||||
:param reset: ``True`` if you would like to unapply the currently active
|
|
||||||
model for the server, if any.
|
|
||||||
|
|
||||||
:returns: the ID of the config switch operation that has been created.
|
|
||||||
"""
|
|
||||||
body: Dict[str, Any] = {"type": "config_switch"}
|
|
||||||
|
|
||||||
if barman_model:
|
|
||||||
body["model_name"] = barman_model
|
|
||||||
elif reset:
|
|
||||||
body["reset"] = reset
|
|
||||||
|
|
||||||
response = self._post_request(
|
|
||||||
f"servers/{barman_server}/operations",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
|
|
||||||
return response["operation_id"]
|
|
||||||
+3
-11
@@ -3,16 +3,13 @@ import abc
|
|||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from patroni.utils import parse_int, parse_bool
|
from patroni.utils import parse_int
|
||||||
|
|
||||||
|
|
||||||
class Tags(abc.ABC):
|
class Tags(abc.ABC):
|
||||||
"""An abstract class that encapsulates all the ``tags`` logic.
|
"""An abstract class that encapsulates all the ``tags`` logic.
|
||||||
|
|
||||||
Child classes that want to use provided facilities must implement ``tags`` abstract property.
|
Child classes that want to use provided facilities must implement ``tags`` abstract property.
|
||||||
|
|
||||||
.. note::
|
|
||||||
Due to backward-compatibility reasons, old tags may have a less strict type conversion than new ones.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -23,7 +20,7 @@ class Tags(abc.ABC):
|
|||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
A custom tag is any tag added to the configuration ``tags`` section that is not one of ``clonefrom``,
|
A custom tag is any tag added to the configuration ``tags`` section that is not one of ``clonefrom``,
|
||||||
``nofailover``, ``noloadbalance``,``nosync`` or ``nostream``.
|
``nofailover``, ``noloadbalance`` or ``nosync``.
|
||||||
|
|
||||||
For most of the Patroni predefined tags, the returning object will only contain them if they are enabled as
|
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.
|
they all are boolean values that default to disabled.
|
||||||
@@ -34,7 +31,7 @@ class Tags(abc.ABC):
|
|||||||
tag value.
|
tag value.
|
||||||
"""
|
"""
|
||||||
return {tag: value for tag, value in tags.items()
|
return {tag: value for tag, value in tags.items()
|
||||||
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync', 'nostream'),
|
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync'),
|
||||||
value,
|
value,
|
||||||
tag == 'nofailover' and 'failover_priority' in tags))}
|
tag == 'nofailover' and 'failover_priority' in tags))}
|
||||||
|
|
||||||
@@ -92,8 +89,3 @@ class Tags(abc.ABC):
|
|||||||
def replicatefrom(self) -> Optional[str]:
|
def replicatefrom(self) -> Optional[str]:
|
||||||
"""Value of ``replicatefrom`` tag, if any."""
|
"""Value of ``replicatefrom`` tag, if any."""
|
||||||
return self.tags.get('replicatefrom')
|
return self.tags.get('replicatefrom')
|
||||||
|
|
||||||
@property
|
|
||||||
def nostream(self) -> bool:
|
|
||||||
"""``True`` if ``nostream`` is ``True``, else ``False``."""
|
|
||||||
return parse_bool(self.tags.get('nostream')) or False
|
|
||||||
|
|||||||
+41
-190
@@ -10,7 +10,6 @@
|
|||||||
:var WHITESPACE_RE: regular expression to match whitespace characters
|
:var WHITESPACE_RE: regular expression to match whitespace characters
|
||||||
"""
|
"""
|
||||||
import errno
|
import errno
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@@ -25,7 +24,6 @@ from shlex import split
|
|||||||
|
|
||||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||||
|
|
||||||
from collections import OrderedDict
|
|
||||||
from dateutil import tz
|
from dateutil import tz
|
||||||
from json import JSONDecoder
|
from json import JSONDecoder
|
||||||
from urllib3.response import HTTPResponse
|
from urllib3.response import HTTPResponse
|
||||||
@@ -35,6 +33,7 @@ from .version import __version__
|
|||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from .dcs import Cluster
|
from .dcs import Cluster
|
||||||
|
from .config import GlobalConfig
|
||||||
|
|
||||||
tzutc = tz.tzutc()
|
tzutc = tz.tzutc()
|
||||||
|
|
||||||
@@ -48,37 +47,6 @@ 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)
|
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:
|
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.
|
"""Recursively compare two dictionaries to check if they are equal in terms of keys and values.
|
||||||
|
|
||||||
@@ -305,152 +273,33 @@ def convert_to_base_unit(value: Union[int, float], unit: str, base_unit: Optiona
|
|||||||
>>> convert_to_base_unit(1, 'GB', '512 MB') is None
|
>>> convert_to_base_unit(1, 'GB', '512 MB') is None
|
||||||
True
|
True
|
||||||
"""
|
"""
|
||||||
base_value, base_unit = strtol(base_unit, False)
|
convert: Dict[str, Dict[str, Union[int, float]]] = {
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
'B': {'B': 1, 'kB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024},
|
||||||
assert isinstance(base_value, int)
|
'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},
|
||||||
convert_tbl = get_conversion_table(base_unit)
|
'ms': {'us': 1.0 / 1000, 'ms': 1, 's': 1000, 'min': 1000 * 60, 'h': 1000 * 60 * 60, 'd': 1000 * 60 * 60 * 24},
|
||||||
# {'TB': 'GB', 'GB': 'MB', ...}
|
's': {'us': 1.0 / (1000 * 1000), 'ms': 1.0 / 1000, 's': 1, 'min': 60, 'h': 60 * 60, 'd': 60 * 60 * 24},
|
||||||
round_order = dict(zip(convert_tbl, itertools.islice(convert_tbl, 1, None)))
|
'min': {'us': 1.0 / (1000 * 1000 * 60), 'ms': 1.0 / (1000 * 60), 's': 1.0 / 60, 'min': 1, 'h': 60, 'd': 60 * 24}
|
||||||
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_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)
|
round_order = {
|
||||||
if parsed_value:
|
'TB': 'GB', 'GB': 'MB', 'MB': 'kB', 'kB': 'B',
|
||||||
return converter(parsed_value, base_unit) or base_value
|
'd': 'h', 'h': 'min', 'min': 's', 's': 'ms', 'ms': 'us'
|
||||||
return base_value
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if unit in round_order:
|
||||||
|
multiplier = convert[base_unit][round_order[unit]]
|
||||||
|
value = round(value / float(multiplier)) * multiplier
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def parse_int(value: Any, base_unit: Optional[str] = None) -> Optional[int]:
|
def parse_int(value: Any, base_unit: Optional[str] = None) -> Optional[int]:
|
||||||
@@ -716,7 +565,7 @@ class Retry(object):
|
|||||||
return self._cur_stoptime or 0
|
return self._cur_stoptime or 0
|
||||||
|
|
||||||
def ensure_deadline(self, timeout: float, raise_ex: Optional[Exception] = None) -> bool:
|
def ensure_deadline(self, timeout: float, raise_ex: Optional[Exception] = None) -> bool:
|
||||||
"""Calculates and checks the remaining deadline time.
|
"""Calculates, sets, and checks the remaining deadline time.
|
||||||
|
|
||||||
:param timeout: if the *deadline* is smaller than the provided *timeout* value raise *raise_ex* exception.
|
:param timeout: if the *deadline* is smaller than the provided *timeout* value raise *raise_ex* exception.
|
||||||
:param raise_ex: the exception object that will be raised if the *deadline* is smaller than provided *timeout*.
|
:param raise_ex: the exception object that will be raised if the *deadline* is smaller than provided *timeout*.
|
||||||
@@ -727,7 +576,8 @@ class Retry(object):
|
|||||||
:raises:
|
:raises:
|
||||||
:class:`Exception`: *raise_ex* if calculated deadline is smaller than provided *timeout*.
|
:class:`Exception`: *raise_ex* if calculated deadline is smaller than provided *timeout*.
|
||||||
"""
|
"""
|
||||||
if self.stoptime - time.time() < timeout:
|
self.deadline = self.stoptime - time.time()
|
||||||
|
if self.deadline < timeout:
|
||||||
if raise_ex:
|
if raise_ex:
|
||||||
raise raise_ex
|
raise raise_ex
|
||||||
return False
|
return False
|
||||||
@@ -910,10 +760,12 @@ def iter_response_objects(response: HTTPResponse) -> Iterator[Dict[str, Any]]:
|
|||||||
prev = chunk[idx:]
|
prev = chunk[idx:]
|
||||||
|
|
||||||
|
|
||||||
def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
|
def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig'] = None) -> Dict[str, Any]:
|
||||||
"""Get a JSON representation of *cluster*.
|
"""Get a JSON representation of *cluster*.
|
||||||
|
|
||||||
:param cluster: the :class:`~patroni.dcs.Cluster` object to be parsed as JSON.
|
:param cluster: the :class:`~patroni.dcs.Cluster` object to be parsed as JSON.
|
||||||
|
:param global_config: optional :class:`~patroni.config.GlobalConfig` object to check the cluster state.
|
||||||
|
if not provided will be instantiated from the `Cluster.config`.
|
||||||
|
|
||||||
:returns: JSON representation of *cluster*.
|
:returns: JSON representation of *cluster*.
|
||||||
|
|
||||||
@@ -922,7 +774,7 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
|
|||||||
* ``members``: list of members in the cluster. Each value is a :class:`dict` that may have the following keys:
|
* ``members``: list of members in the cluster. Each value is a :class:`dict` that may have the following keys:
|
||||||
|
|
||||||
* ``name``: the name of the host (unique in the cluster). The ``members`` list is sorted by this key;
|
* ``name``: the name of the host (unique in the cluster). The ``members`` list is sorted by this key;
|
||||||
* ``role``: ``leader``, ``standby_leader``, ``sync_standby``, ``quorum_standby``, or ``replica``;
|
* ``role``: ``leader``, ``standby_leader``, ``sync_standby``, or ``replica``;
|
||||||
* ``state``: ``stopping``, ``stopped``, ``stop failed``, ``crashed``, ``running``, ``starting``,
|
* ``state``: ``stopping``, ``stopped``, ``stop failed``, ``crashed``, ``running``, ``starting``,
|
||||||
``start failed``, ``restarting``, ``restart failed``, ``initializing new cluster``, ``initdb failed``,
|
``start failed``, ``restarting``, ``restart failed``, ``initializing new cluster``, ``initdb failed``,
|
||||||
``running custom bootstrap script``, ``custom bootstrap failed``, or ``creating replica``;
|
``running custom bootstrap script``, ``custom bootstrap failed``, or ``creating replica``;
|
||||||
@@ -942,19 +794,18 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
|
|||||||
* ``from``: name of the member to be demoted;
|
* ``from``: name of the member to be demoted;
|
||||||
* ``to``: name of the member to be promoted.
|
* ``to``: name of the member to be promoted.
|
||||||
"""
|
"""
|
||||||
from . import global_config
|
if not global_config:
|
||||||
|
from patroni.config import get_global_config
|
||||||
config = global_config.from_cluster(cluster)
|
global_config = get_global_config(cluster)
|
||||||
leader_name = cluster.leader.name if cluster.leader else None
|
leader_name = cluster.leader.name if cluster.leader else None
|
||||||
cluster_lsn = cluster.last_lsn or 0
|
cluster_lsn = cluster.last_lsn or 0
|
||||||
|
|
||||||
ret: Dict[str, Any] = {'members': []}
|
ret: Dict[str, Any] = {'members': []}
|
||||||
sync_role = 'quorum_standby' if config.is_quorum_commit_mode else 'sync_standby'
|
|
||||||
for m in cluster.members:
|
for m in cluster.members:
|
||||||
if m.name == leader_name:
|
if m.name == leader_name:
|
||||||
role = 'standby_leader' if config.is_standby_cluster else 'leader'
|
role = 'standby_leader' if global_config.is_standby_cluster else 'leader'
|
||||||
elif cluster.sync.matches(m.name):
|
elif cluster.sync.matches(m.name):
|
||||||
role = sync_role
|
role = 'sync_standby'
|
||||||
else:
|
else:
|
||||||
role = 'replica'
|
role = 'replica'
|
||||||
|
|
||||||
@@ -965,7 +816,7 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
|
|||||||
member['host'] = conn_kwargs['host']
|
member['host'] = conn_kwargs['host']
|
||||||
if conn_kwargs.get('port'):
|
if conn_kwargs.get('port'):
|
||||||
member['port'] = int(conn_kwargs['port'])
|
member['port'] = int(conn_kwargs['port'])
|
||||||
optional_attributes = ('timeline', 'pending_restart', 'pending_restart_reason', 'scheduled_restart', 'tags')
|
optional_attributes = ('timeline', 'pending_restart', 'scheduled_restart', 'tags')
|
||||||
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
|
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
|
||||||
|
|
||||||
if m.name != leader_name:
|
if m.name != leader_name:
|
||||||
@@ -982,7 +833,7 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
|
|||||||
# sort members by name for consistency
|
# sort members by name for consistency
|
||||||
cmp: Callable[[Dict[str, Any]], bool] = lambda m: m['name']
|
cmp: Callable[[Dict[str, Any]], bool] = lambda m: m['name']
|
||||||
ret['members'].sort(key=cmp)
|
ret['members'].sort(key=cmp)
|
||||||
if config.is_paused:
|
if global_config.is_paused:
|
||||||
ret['pause'] = True
|
ret['pause'] = True
|
||||||
if cluster.failover and cluster.failover.scheduled_at:
|
if cluster.failover and cluster.failover.scheduled_at:
|
||||||
ret['scheduled_switchover'] = {'at': cluster.failover.scheduled_at.isoformat()}
|
ret['scheduled_switchover'] = {'at': cluster.failover.scheduled_at.isoformat()}
|
||||||
|
|||||||
+2
-61
@@ -16,49 +16,6 @@ from .collections import CaseInsensitiveSet
|
|||||||
from .dcs import dcs_modules
|
from .dcs import dcs_modules
|
||||||
from .exceptions import ConfigParseError
|
from .exceptions import ConfigParseError
|
||||||
from .utils import parse_int, split_host_port, data_directory_is_empty, get_major_version
|
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:
|
def data_directory_empty(data_dir: str) -> bool:
|
||||||
@@ -980,20 +937,6 @@ validate_etcd = {
|
|||||||
schema = Schema({
|
schema = Schema({
|
||||||
"name": str,
|
"name": str,
|
||||||
"scope": 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("ctl"): {
|
||||||
Optional("insecure"): bool,
|
Optional("insecure"): bool,
|
||||||
Optional("cacert"): str,
|
Optional("cacert"): str,
|
||||||
@@ -1107,8 +1050,7 @@ schema = Schema({
|
|||||||
Optional("key"): str,
|
Optional("key"): str,
|
||||||
Optional("key_password"): str,
|
Optional("key_password"): str,
|
||||||
Optional("verify"): bool,
|
Optional("verify"): bool,
|
||||||
Optional("set_acls"): dict,
|
Optional("set_acls"): dict
|
||||||
Optional("auth_data"): dict,
|
|
||||||
},
|
},
|
||||||
"kubernetes": {
|
"kubernetes": {
|
||||||
"labels": {},
|
"labels": {},
|
||||||
@@ -1172,7 +1114,6 @@ schema = Schema({
|
|||||||
Optional("clonefrom"): bool,
|
Optional("clonefrom"): bool,
|
||||||
Optional("noloadbalance"): bool,
|
Optional("noloadbalance"): bool,
|
||||||
Optional("replicatefrom"): str,
|
Optional("replicatefrom"): str,
|
||||||
Optional("nosync"): bool,
|
Optional("nosync"): bool
|
||||||
Optional("nostream"): bool
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -136,4 +136,3 @@ tags:
|
|||||||
noloadbalance: false
|
noloadbalance: false
|
||||||
clonefrom: false
|
clonefrom: false
|
||||||
nosync: false
|
nosync: false
|
||||||
nostream: false
|
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ pysyncobj>=0.3.8
|
|||||||
cryptography>=1.4
|
cryptography>=1.4
|
||||||
psutil>=2.0.0
|
psutil>=2.0.0
|
||||||
ydiff>=1.2.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'],
|
EXTRAS_REQUIRE = {'aws': ['boto3'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
|
||||||
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
|
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
|
||||||
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography'], 'jsonlogger': ['python-json-logger']}
|
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
|
||||||
|
|
||||||
# Add here all kinds of additional classifiers as defined under
|
# Add here all kinds of additional classifiers as defined under
|
||||||
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
|
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
|
||||||
@@ -54,8 +54,7 @@ CONSOLE_SCRIPTS = ['patroni = patroni.__main__:main',
|
|||||||
'patronictl = patroni.ctl:ctl',
|
'patronictl = patroni.ctl:ctl',
|
||||||
'patroni_raft_controller = patroni.raft_controller:main',
|
'patroni_raft_controller = patroni.raft_controller:main',
|
||||||
"patroni_wale_restore = patroni.scripts.wale_restore:main",
|
"patroni_wale_restore = patroni.scripts.wale_restore:main",
|
||||||
"patroni_aws = patroni.scripts.aws:main",
|
"patroni_aws = patroni.scripts.aws:main"]
|
||||||
"patroni_barman = patroni.scripts.barman.cli:main"]
|
|
||||||
|
|
||||||
|
|
||||||
class _Command(Command):
|
class _Command(Command):
|
||||||
|
|||||||
+4
-6
@@ -12,7 +12,6 @@ import patroni.psycopg as psycopg
|
|||||||
from patroni.dcs import Leader, Member
|
from patroni.dcs import Leader, Member
|
||||||
from patroni.postgresql import Postgresql
|
from patroni.postgresql import Postgresql
|
||||||
from patroni.postgresql.config import ConfigHandler
|
from patroni.postgresql.config import ConfigHandler
|
||||||
from patroni.postgresql.mpp import get_mpp
|
|
||||||
from patroni.utils import RetryFailedError, tzutc
|
from patroni.utils import RetryFailedError, tzutc
|
||||||
|
|
||||||
|
|
||||||
@@ -151,8 +150,6 @@ class MockCursor(object):
|
|||||||
self.results = [(False, 2)]
|
self.results = [(False, 2)]
|
||||||
elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'):
|
elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'):
|
||||||
self.results = [(datetime.datetime.now(tzutc),)]
|
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'):
|
elif sql.startswith('SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings'):
|
||||||
self.results = [('data_directory', 'data'),
|
self.results = [('data_directory', 'data'),
|
||||||
('hba_file', os.path.join('data', 'pg_hba.conf')),
|
('hba_file', os.path.join('data', 'pg_hba.conf')),
|
||||||
@@ -170,6 +167,8 @@ class MockCursor(object):
|
|||||||
('cluster_name', 'my_cluster')]
|
('cluster_name', 'my_cluster')]
|
||||||
elif sql.startswith('SELECT name, setting'):
|
elif sql.startswith('SELECT name, setting'):
|
||||||
self.results = GET_PG_SETTINGS_RESULT
|
self.results = GET_PG_SETTINGS_RESULT
|
||||||
|
elif sql.startswith('SELECT COUNT(*) FROM pg_catalog.pg_settings'):
|
||||||
|
self.results = [(0,)]
|
||||||
elif sql.startswith('IDENTIFY_SYSTEM'):
|
elif sql.startswith('IDENTIFY_SYSTEM'):
|
||||||
self.results = [('1', 3, '0/402EEC0', '')]
|
self.results = [('1', 3, '0/402EEC0', '')]
|
||||||
elif sql.startswith('TIMELINE_HISTORY '):
|
elif sql.startswith('TIMELINE_HISTORY '):
|
||||||
@@ -253,7 +252,7 @@ class PostgresInit(unittest.TestCase):
|
|||||||
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='primary'))
|
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='primary'))
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
data_dir = os.path.join('data', 'test0')
|
data_dir = os.path.join('data', 'test0')
|
||||||
config = {'name': 'postgresql0', 'scope': 'batman', 'data_dir': data_dir,
|
self.p = Postgresql({'name': 'postgresql0', 'scope': 'batman', 'data_dir': data_dir,
|
||||||
'config_dir': data_dir, 'retry_timeout': 10,
|
'config_dir': data_dir, 'retry_timeout': 10,
|
||||||
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
|
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
|
||||||
'listen': '127.0.0.2, 127.0.0.3:5432',
|
'listen': '127.0.0.2, 127.0.0.3:5432',
|
||||||
@@ -269,8 +268,7 @@ class PostgresInit(unittest.TestCase):
|
|||||||
'pg_ident': ['krb realm postgres'],
|
'pg_ident': ['krb realm postgres'],
|
||||||
'callbacks': {'on_start': 'true', 'on_stop': 'true', 'on_reload': 'true',
|
'callbacks': {'on_start': 'true', 'on_stop': 'true', 'on_reload': 'true',
|
||||||
'on_restart': 'true', 'on_role_change': 'true'},
|
'on_restart': 'true', 'on_role_change': 'true'},
|
||||||
'citus': {'group': 0, 'database': 'citus'}}
|
'citus': {'group': 0, 'database': 'citus'}})
|
||||||
self.p = Postgresql(config, get_mpp(config))
|
|
||||||
|
|
||||||
|
|
||||||
class BaseTestPostgresql(PostgresInit):
|
class BaseTestPostgresql(PostgresInit):
|
||||||
|
|||||||
+21
-27
@@ -8,12 +8,11 @@ from io import BytesIO as IO
|
|||||||
from mock import Mock, PropertyMock, patch
|
from mock import Mock, PropertyMock, patch
|
||||||
from socketserver import ThreadingMixIn
|
from socketserver import ThreadingMixIn
|
||||||
|
|
||||||
from patroni import global_config
|
|
||||||
from patroni.api import RestApiHandler, RestApiServer
|
from patroni.api import RestApiHandler, RestApiServer
|
||||||
|
from patroni.config import GlobalConfig
|
||||||
from patroni.dcs import ClusterConfig, Member
|
from patroni.dcs import ClusterConfig, Member
|
||||||
from patroni.exceptions import PostgresConnectionException
|
from patroni.exceptions import PostgresConnectionException
|
||||||
from patroni.ha import _MemberStatus
|
from patroni.ha import _MemberStatus
|
||||||
from patroni.postgresql.config import get_param_diff
|
|
||||||
from patroni.psycopg import OperationalError
|
from patroni.psycopg import OperationalError
|
||||||
from patroni.utils import RetryFailedError, tzutc
|
from patroni.utils import RetryFailedError, tzutc
|
||||||
|
|
||||||
@@ -55,13 +54,13 @@ class MockPostgresql:
|
|||||||
major_version = 90600
|
major_version = 90600
|
||||||
sysid = 'dummysysid'
|
sysid = 'dummysysid'
|
||||||
scope = 'dummy'
|
scope = 'dummy'
|
||||||
pending_restart_reason = {}
|
pending_restart = True
|
||||||
wal_name = 'wal'
|
wal_name = 'wal'
|
||||||
lsn_name = 'lsn'
|
lsn_name = 'lsn'
|
||||||
wal_flush = '_flush'
|
wal_flush = '_flush'
|
||||||
POSTMASTER_START_TIME = 'pg_catalog.pg_postmaster_start_time()'
|
POSTMASTER_START_TIME = 'pg_catalog.pg_postmaster_start_time()'
|
||||||
TL_LSN = 'CASE WHEN pg_catalog.pg_is_in_recovery()'
|
TL_LSN = 'CASE WHEN pg_catalog.pg_is_in_recovery()'
|
||||||
mpp_handler = Mock()
|
citus_handler = Mock()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def postmaster_start_time():
|
def postmaster_start_time():
|
||||||
@@ -149,9 +148,16 @@ class MockLogger(object):
|
|||||||
records_lost = 1
|
records_lost = 1
|
||||||
|
|
||||||
|
|
||||||
|
class MockConfig(object):
|
||||||
|
|
||||||
|
def get_global_config(self, _):
|
||||||
|
return GlobalConfig({})
|
||||||
|
|
||||||
|
|
||||||
class MockPatroni(object):
|
class MockPatroni(object):
|
||||||
|
|
||||||
ha = MockHa()
|
ha = MockHa()
|
||||||
|
config = MockConfig()
|
||||||
postgresql = ha.state_handler
|
postgresql = ha.state_handler
|
||||||
dcs = Mock()
|
dcs = Mock()
|
||||||
logger = MockLogger()
|
logger = MockLogger()
|
||||||
@@ -203,9 +209,9 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
|
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
|
||||||
|
|
||||||
def test_do_GET(self):
|
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.last_lsn = 20
|
||||||
with patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True)):
|
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
|
||||||
|
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=True)):
|
||||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M')
|
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M')
|
||||||
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB')
|
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB')
|
||||||
@@ -222,17 +228,13 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
Mock(return_value={'role': 'replica', 'sync_standby': True})):
|
Mock(return_value={'role': 'replica', 'sync_standby': True})):
|
||||||
MockRestApiServer(RestApiHandler, 'GET /synchronous')
|
MockRestApiServer(RestApiHandler, 'GET /synchronous')
|
||||||
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
|
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
|
||||||
with patch.object(RestApiHandler, 'get_postgresql_status',
|
|
||||||
Mock(return_value={'role': 'replica', 'quorum_standby': True})):
|
|
||||||
MockRestApiServer(RestApiHandler, 'GET /quorum')
|
|
||||||
MockRestApiServer(RestApiHandler, 'GET /read-only-quorum')
|
|
||||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
|
||||||
|
MockPatroni.dcs.cluster.sync.members = []
|
||||||
MockRestApiServer(RestApiHandler, 'GET /asynchronous')
|
MockRestApiServer(RestApiHandler, 'GET /asynchronous')
|
||||||
with patch.object(MockHa, 'is_leader', Mock(return_value=True)):
|
with patch.object(MockHa, 'is_leader', Mock(return_value=True)):
|
||||||
MockRestApiServer(RestApiHandler, 'GET /replica')
|
MockRestApiServer(RestApiHandler, 'GET /replica')
|
||||||
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
|
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
|
||||||
MockRestApiServer(RestApiHandler, 'GET /read-only-quorum')
|
with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)):
|
||||||
with patch.object(global_config.__class__, 'is_standby_cluster', Mock(return_value=True)):
|
|
||||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
||||||
MockPatroni.dcs.cluster = None
|
MockPatroni.dcs.cluster = None
|
||||||
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'primary'})):
|
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'primary'})):
|
||||||
@@ -242,8 +244,8 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /primary'))
|
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /primary'))
|
||||||
with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, None, None, '')])):
|
with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, None, None, '')])):
|
||||||
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
|
||||||
with patch.object(global_config.__class__, 'is_standby_cluster', Mock(return_value=True)), \
|
with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)), \
|
||||||
patch.object(global_config.__class__, 'is_paused', Mock(return_value=True)):
|
patch.object(GlobalConfig, 'is_paused', Mock(return_value=True)):
|
||||||
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
|
||||||
|
|
||||||
# test tags
|
# test tags
|
||||||
@@ -473,7 +475,7 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
request = make_request(role='primary', postgres_version='9.5.2')
|
request = make_request(role='primary', postgres_version='9.5.2')
|
||||||
MockRestApiServer(RestApiHandler, request)
|
MockRestApiServer(RestApiHandler, request)
|
||||||
|
|
||||||
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
|
with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
|
||||||
MockRestApiServer(RestApiHandler, make_request(schedule='2016-08-42 12:45TZ+1', role='primary'))
|
MockRestApiServer(RestApiHandler, make_request(schedule='2016-08-42 12:45TZ+1', role='primary'))
|
||||||
# Valid timeout
|
# Valid timeout
|
||||||
MockRestApiServer(RestApiHandler, make_request(timeout='60s'))
|
MockRestApiServer(RestApiHandler, make_request(timeout='60s'))
|
||||||
@@ -535,7 +537,7 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
|
|
||||||
# Switchover in pause mode
|
# Switchover in pause mode
|
||||||
with patch.object(RestApiHandler, 'write_response') as response_mock, \
|
with patch.object(RestApiHandler, 'write_response') as response_mock, \
|
||||||
patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
|
patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
|
||||||
MockRestApiServer(RestApiHandler, request)
|
MockRestApiServer(RestApiHandler, request)
|
||||||
response_mock.assert_called_with(
|
response_mock.assert_called_with(
|
||||||
400, 'Switchover is possible only to a specific candidate in a paused state')
|
400, 'Switchover is possible only to a specific candidate in a paused state')
|
||||||
@@ -544,8 +546,7 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
for is_synchronous_mode, response in (
|
for is_synchronous_mode, response in (
|
||||||
(True, 'switchover is not possible: can not find sync_standby'),
|
(True, 'switchover is not possible: can not find sync_standby'),
|
||||||
(False, 'switchover is not possible: cluster does not have members except leader')):
|
(False, 'switchover is not possible: cluster does not have members except leader')):
|
||||||
with patch.object(global_config.__class__, 'is_synchronous_mode',
|
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)), \
|
||||||
PropertyMock(return_value=is_synchronous_mode)), \
|
|
||||||
patch.object(RestApiHandler, 'write_response') as response_mock:
|
patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||||
MockRestApiServer(RestApiHandler, request)
|
MockRestApiServer(RestApiHandler, request)
|
||||||
response_mock.assert_called_with(412, response)
|
response_mock.assert_called_with(412, response)
|
||||||
@@ -570,8 +571,7 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
cluster.sync.matches.return_value = False
|
cluster.sync.matches.return_value = False
|
||||||
for is_synchronous_mode, response in (
|
for is_synchronous_mode, response in (
|
||||||
(True, 'candidate name does not match with sync_standby'), (False, 'candidate does not exists')):
|
(True, 'candidate name does not match with sync_standby'), (False, 'candidate does not exists')):
|
||||||
with patch.object(global_config.__class__, 'is_synchronous_mode',
|
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)), \
|
||||||
PropertyMock(return_value=is_synchronous_mode)), \
|
|
||||||
patch.object(RestApiHandler, 'write_response') as response_mock:
|
patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||||
MockRestApiServer(RestApiHandler, request)
|
MockRestApiServer(RestApiHandler, request)
|
||||||
response_mock.assert_called_with(412, response)
|
response_mock.assert_called_with(412, response)
|
||||||
@@ -632,7 +632,7 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
|
|
||||||
# Schedule in paused mode
|
# Schedule in paused mode
|
||||||
with patch.object(RestApiHandler, 'write_response') as response_mock, \
|
with patch.object(RestApiHandler, 'write_response') as response_mock, \
|
||||||
patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
|
patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
|
||||||
dcs.manual_failover.return_value = False
|
dcs.manual_failover.return_value = False
|
||||||
MockRestApiServer(RestApiHandler, request)
|
MockRestApiServer(RestApiHandler, request)
|
||||||
response_mock.assert_called_with(400, "Can't schedule switchover in the paused state")
|
response_mock.assert_called_with(400, "Can't schedule switchover in the paused state")
|
||||||
@@ -678,12 +678,6 @@ class TestRestApiHandler(unittest.TestCase):
|
|||||||
MockRestApiServer(RestApiHandler, post + '0\n\n')
|
MockRestApiServer(RestApiHandler, post + '0\n\n')
|
||||||
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
|
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):
|
class TestRestApiServer(unittest.TestCase):
|
||||||
|
|
||||||
|
|||||||
@@ -1,765 +0,0 @@
|
|||||||
import logging
|
|
||||||
import mock
|
|
||||||
from mock import MagicMock, Mock, patch
|
|
||||||
import unittest
|
|
||||||
from urllib3.exceptions import MaxRetryError
|
|
||||||
|
|
||||||
from patroni.scripts.barman.cli import main
|
|
||||||
from patroni.scripts.barman.config_switch import (ExitCode as BarmanConfigSwitchExitCode, _should_skip_switch,
|
|
||||||
_switch_config, run_barman_config_switch)
|
|
||||||
from patroni.scripts.barman.recover import ExitCode as BarmanRecoverExitCode, _restore_backup, run_barman_recover
|
|
||||||
from patroni.scripts.barman.utils import ApiNotOk, OperationStatus, PgBackupApi, RetriesExceeded, set_up_logging
|
|
||||||
|
|
||||||
|
|
||||||
API_URL = "http://localhost:7480"
|
|
||||||
BARMAN_SERVER = "my_server"
|
|
||||||
BARMAN_MODEL = "my_model"
|
|
||||||
BACKUP_ID = "backup_id"
|
|
||||||
SSH_COMMAND = "ssh postgres@localhost"
|
|
||||||
DATA_DIRECTORY = "/path/to/pgdata"
|
|
||||||
LOOP_WAIT = 10
|
|
||||||
RETRY_WAIT = 2
|
|
||||||
MAX_RETRIES = 5
|
|
||||||
|
|
||||||
|
|
||||||
# stuff from patroni.scripts.barman.utils
|
|
||||||
|
|
||||||
@patch("logging.basicConfig")
|
|
||||||
def test_set_up_logging(mock_log_config):
|
|
||||||
log_file = "/path/to/some/file.log"
|
|
||||||
set_up_logging(log_file)
|
|
||||||
mock_log_config.assert_called_once_with(filename=log_file, level=logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)s: %(message)s")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPgBackupApi(unittest.TestCase):
|
|
||||||
|
|
||||||
@patch.object(PgBackupApi, "_ensure_api_ok", Mock())
|
|
||||||
@patch("patroni.scripts.barman.utils.PoolManager", MagicMock())
|
|
||||||
def setUp(self):
|
|
||||||
self.api = PgBackupApi(API_URL, None, None, RETRY_WAIT, MAX_RETRIES)
|
|
||||||
# Reset the mock as the same instance is used across tests
|
|
||||||
self.api._http.request.reset_mock()
|
|
||||||
self.api._http.request.side_effect = None
|
|
||||||
|
|
||||||
def test__build_full_url(self):
|
|
||||||
self.assertEqual(self.api._build_full_url("/some/path"), f"{API_URL}/some/path")
|
|
||||||
|
|
||||||
@patch("json.loads")
|
|
||||||
def test__deserialize_response(self, mock_json_loads):
|
|
||||||
mock_response = MagicMock()
|
|
||||||
self.assertIsNotNone(self.api._deserialize_response(mock_response))
|
|
||||||
mock_json_loads.assert_called_once_with(mock_response.data.decode("utf-8"))
|
|
||||||
|
|
||||||
@patch("json.dumps")
|
|
||||||
def test__serialize_request(self, mock_json_dumps):
|
|
||||||
body = "some_body"
|
|
||||||
ret = self.api._serialize_request(body)
|
|
||||||
self.assertIsNotNone(ret)
|
|
||||||
mock_json_dumps.assert_called_once_with(body)
|
|
||||||
mock_json_dumps.return_value.encode.assert_called_once_with("utf-8")
|
|
||||||
|
|
||||||
@patch.object(PgBackupApi, "_deserialize_response", Mock(return_value="test"))
|
|
||||||
def test__get_request(self):
|
|
||||||
mock_request = self.api._http.request
|
|
||||||
|
|
||||||
# with no error
|
|
||||||
self.assertEqual(self.api._get_request("/some/path"), "test")
|
|
||||||
mock_request.assert_called_once_with("GET", f"{API_URL}/some/path")
|
|
||||||
|
|
||||||
# with MaxRetryError
|
|
||||||
http_error = MaxRetryError(self.api._http, f"{API_URL}/some/path")
|
|
||||||
mock_request.side_effect = http_error
|
|
||||||
|
|
||||||
with self.assertRaises(RetriesExceeded) as exc:
|
|
||||||
self.assertIsNone(self.api._get_request("/some/path"))
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
str(exc.exception),
|
|
||||||
"Failed to perform a GET request to http://localhost:7480/some/path"
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch.object(PgBackupApi, "_deserialize_response", Mock(return_value="test"))
|
|
||||||
@patch.object(PgBackupApi, "_serialize_request")
|
|
||||||
def test__post_request(self, mock_serialize):
|
|
||||||
mock_request = self.api._http.request
|
|
||||||
|
|
||||||
# with no error
|
|
||||||
self.assertEqual(self.api._post_request("/some/path", "some body"), "test")
|
|
||||||
mock_serialize.assert_called_once_with("some body")
|
|
||||||
mock_request.assert_called_once_with("POST", f"{API_URL}/some/path", body=mock_serialize.return_value,
|
|
||||||
headers={"Content-Type": "application/json"})
|
|
||||||
|
|
||||||
# with HTTPError
|
|
||||||
http_error = MaxRetryError(self.api._http, f"{API_URL}/some/path")
|
|
||||||
mock_request.side_effect = http_error
|
|
||||||
|
|
||||||
with self.assertRaises(RetriesExceeded) as exc:
|
|
||||||
self.assertIsNone(self.api._post_request("/some/path", "some body"))
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
str(exc.exception),
|
|
||||||
f"Failed to perform a POST request to http://localhost:7480/some/path with {mock_serialize.return_value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch.object(PgBackupApi, "_get_request")
|
|
||||||
def test__ensure_api_ok(self, mock_get_request):
|
|
||||||
# API ok
|
|
||||||
mock_get_request.return_value = "OK"
|
|
||||||
self.assertIsNone(self.api._ensure_api_ok())
|
|
||||||
|
|
||||||
# API not ok
|
|
||||||
mock_get_request.return_value = "random"
|
|
||||||
|
|
||||||
with self.assertRaises(ApiNotOk) as exc:
|
|
||||||
self.assertIsNone(self.api._ensure_api_ok())
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
str(exc.exception),
|
|
||||||
"pg-backup-api is currently not up and running at http://localhost:7480: random",
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("patroni.scripts.barman.utils.OperationStatus")
|
|
||||||
@patch("logging.warning")
|
|
||||||
@patch("time.sleep")
|
|
||||||
@patch.object(PgBackupApi, "_get_request")
|
|
||||||
def test_get_operation_status(self, mock_get_request, mock_sleep, mock_logging, mock_op_status):
|
|
||||||
# well formed response
|
|
||||||
mock_get_request.return_value = {"status": "some status"}
|
|
||||||
mock_op_status.__getitem__.return_value = "SOME_STATUS"
|
|
||||||
self.assertEqual(self.api.get_operation_status(BARMAN_SERVER, "some_id"), "SOME_STATUS")
|
|
||||||
mock_get_request.assert_called_once_with(f"servers/{BARMAN_SERVER}/operations/some_id")
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
mock_logging.assert_not_called()
|
|
||||||
mock_op_status.__getitem__.assert_called_once_with("some status")
|
|
||||||
|
|
||||||
# malformed response
|
|
||||||
mock_get_request.return_value = {"statuss": "some status"}
|
|
||||||
|
|
||||||
with self.assertRaises(RetriesExceeded) as exc:
|
|
||||||
self.api.get_operation_status(BARMAN_SERVER, "some_id")
|
|
||||||
|
|
||||||
self.assertEqual(str(exc.exception),
|
|
||||||
"Maximum number of retries exceeded for method PgBackupApi.get_operation_status.")
|
|
||||||
|
|
||||||
self.assertEqual(mock_sleep.call_count, self.api.max_retries)
|
|
||||||
mock_sleep.assert_has_calls([mock.call(self.api.retry_wait)] * self.api.max_retries)
|
|
||||||
|
|
||||||
self.assertEqual(mock_logging.call_count, self.api.max_retries)
|
|
||||||
for i in range(mock_logging.call_count):
|
|
||||||
call_args = mock_logging.call_args_list[i][0]
|
|
||||||
self.assertEqual(len(call_args), 5)
|
|
||||||
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
|
|
||||||
self.assertEqual(call_args[1], i + 1)
|
|
||||||
self.assertEqual(call_args[2], self.api.max_retries)
|
|
||||||
self.assertEqual(call_args[3], "PgBackupApi.get_operation_status")
|
|
||||||
self.assertIsInstance(call_args[4], KeyError)
|
|
||||||
self.assertEqual(call_args[4].args, ('status',))
|
|
||||||
|
|
||||||
@patch("logging.warning")
|
|
||||||
@patch("time.sleep")
|
|
||||||
@patch.object(PgBackupApi, "_post_request")
|
|
||||||
def test_create_recovery_operation(self, mock_post_request, mock_sleep, mock_logging):
|
|
||||||
# well formed response
|
|
||||||
mock_post_request.return_value = {"operation_id": "some_id"}
|
|
||||||
self.assertEqual(
|
|
||||||
self.api.create_recovery_operation(BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY),
|
|
||||||
"some_id",
|
|
||||||
)
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
mock_logging.assert_not_called()
|
|
||||||
mock_post_request.assert_called_once_with(
|
|
||||||
f"servers/{BARMAN_SERVER}/operations",
|
|
||||||
{
|
|
||||||
"type": "recovery",
|
|
||||||
"backup_id": BACKUP_ID,
|
|
||||||
"remote_ssh_command": SSH_COMMAND,
|
|
||||||
"destination_directory": DATA_DIRECTORY,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# malformed response
|
|
||||||
mock_post_request.return_value = {"operation_idd": "some_id"}
|
|
||||||
|
|
||||||
with self.assertRaises(RetriesExceeded) as exc:
|
|
||||||
self.api.create_recovery_operation(BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY)
|
|
||||||
|
|
||||||
self.assertEqual(str(exc.exception),
|
|
||||||
"Maximum number of retries exceeded for method PgBackupApi.create_recovery_operation.")
|
|
||||||
|
|
||||||
self.assertEqual(mock_sleep.call_count, self.api.max_retries)
|
|
||||||
|
|
||||||
mock_sleep.assert_has_calls([mock.call(self.api.retry_wait)] * self.api.max_retries)
|
|
||||||
|
|
||||||
self.assertEqual(mock_logging.call_count, self.api.max_retries)
|
|
||||||
for i in range(mock_logging.call_count):
|
|
||||||
call_args = mock_logging.call_args_list[i][0]
|
|
||||||
self.assertEqual(len(call_args), 5)
|
|
||||||
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
|
|
||||||
self.assertEqual(call_args[1], i + 1)
|
|
||||||
self.assertEqual(call_args[2], self.api.max_retries)
|
|
||||||
self.assertEqual(call_args[3], "PgBackupApi.create_recovery_operation")
|
|
||||||
self.assertIsInstance(call_args[4], KeyError)
|
|
||||||
self.assertEqual(call_args[4].args, ('operation_id',))
|
|
||||||
|
|
||||||
@patch("logging.warning")
|
|
||||||
@patch("time.sleep")
|
|
||||||
@patch.object(PgBackupApi, "_post_request")
|
|
||||||
def test_create_config_switch_operation(self, mock_post_request, mock_sleep, mock_logging):
|
|
||||||
# well formed response -- sample 1
|
|
||||||
mock_post_request.return_value = {"operation_id": "some_id"}
|
|
||||||
self.assertEqual(
|
|
||||||
self.api.create_config_switch_operation(BARMAN_SERVER, BARMAN_MODEL, None),
|
|
||||||
"some_id",
|
|
||||||
)
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
mock_logging.assert_not_called()
|
|
||||||
mock_post_request.assert_called_once_with(
|
|
||||||
f"servers/{BARMAN_SERVER}/operations",
|
|
||||||
{
|
|
||||||
"type": "config_switch",
|
|
||||||
"model_name": BARMAN_MODEL,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# well formed response -- sample 2
|
|
||||||
mock_post_request.reset_mock()
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
self.api.create_config_switch_operation(BARMAN_SERVER, None, True),
|
|
||||||
"some_id",
|
|
||||||
)
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
mock_logging.assert_not_called()
|
|
||||||
mock_post_request.assert_called_once_with(
|
|
||||||
f"servers/{BARMAN_SERVER}/operations",
|
|
||||||
{
|
|
||||||
"type": "config_switch",
|
|
||||||
"reset": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# malformed response
|
|
||||||
mock_post_request.return_value = {"operation_idd": "some_id"}
|
|
||||||
|
|
||||||
with self.assertRaises(RetriesExceeded) as exc:
|
|
||||||
self.api.create_config_switch_operation(BARMAN_SERVER, BARMAN_MODEL, None)
|
|
||||||
|
|
||||||
self.assertEqual(str(exc.exception),
|
|
||||||
"Maximum number of retries exceeded for method PgBackupApi.create_config_switch_operation.")
|
|
||||||
|
|
||||||
self.assertEqual(mock_sleep.call_count, self.api.max_retries)
|
|
||||||
|
|
||||||
mock_sleep.assert_has_calls([mock.call(self.api.retry_wait)] * self.api.max_retries)
|
|
||||||
|
|
||||||
self.assertEqual(mock_logging.call_count, self.api.max_retries)
|
|
||||||
for i in range(mock_logging.call_count):
|
|
||||||
call_args = mock_logging.call_args_list[i][0]
|
|
||||||
self.assertEqual(len(call_args), 5)
|
|
||||||
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
|
|
||||||
self.assertEqual(call_args[1], i + 1)
|
|
||||||
self.assertEqual(call_args[2], self.api.max_retries)
|
|
||||||
self.assertEqual(call_args[3], "PgBackupApi.create_config_switch_operation")
|
|
||||||
self.assertIsInstance(call_args[4], KeyError)
|
|
||||||
self.assertEqual(call_args[4].args, ('operation_id',))
|
|
||||||
|
|
||||||
|
|
||||||
# stuff from patroni.scripts.barman.recover
|
|
||||||
|
|
||||||
|
|
||||||
class TestBarmanRecover(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.api = MagicMock()
|
|
||||||
# Reset the mock as the same instance is used across tests
|
|
||||||
self.api._http.request.reset_mock()
|
|
||||||
self.api._http.request.side_effect = None
|
|
||||||
|
|
||||||
@patch("time.sleep")
|
|
||||||
@patch("logging.info")
|
|
||||||
@patch("logging.error")
|
|
||||||
def test__restore_backup(self, mock_log_error, mock_log_info, mock_sleep):
|
|
||||||
mock_create_op = self.api.create_recovery_operation
|
|
||||||
mock_get_status = self.api.get_operation_status
|
|
||||||
|
|
||||||
# successful fast restore
|
|
||||||
mock_create_op.return_value = "some_id"
|
|
||||||
mock_get_status.return_value = OperationStatus.DONE
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
|
|
||||||
BarmanRecoverExitCode.RECOVERY_DONE,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_create_op.assert_called_once_with(BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY)
|
|
||||||
mock_get_status.assert_called_once_with(BARMAN_SERVER, "some_id")
|
|
||||||
mock_log_info.assert_has_calls([
|
|
||||||
mock.call("Created the recovery operation with ID %s", "some_id"),
|
|
||||||
mock.call("Recovery operation finished successfully."),
|
|
||||||
])
|
|
||||||
mock_log_error.assert_not_called()
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
|
|
||||||
# successful slow restore
|
|
||||||
mock_create_op.reset_mock()
|
|
||||||
mock_get_status.reset_mock()
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_get_status.side_effect = [OperationStatus.IN_PROGRESS] * 20 + [OperationStatus.DONE]
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
|
|
||||||
BarmanRecoverExitCode.RECOVERY_DONE,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_create_op.assert_called_once()
|
|
||||||
|
|
||||||
self.assertEqual(mock_get_status.call_count, 21)
|
|
||||||
mock_get_status.assert_has_calls([mock.call(BARMAN_SERVER, "some_id")] * 21)
|
|
||||||
|
|
||||||
self.assertEqual(mock_log_info.call_count, 22)
|
|
||||||
mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")]
|
|
||||||
+ [mock.call("Recovery operation %s is still in progress", "some_id")] * 20
|
|
||||||
+ [mock.call("Recovery operation finished successfully.")])
|
|
||||||
|
|
||||||
mock_log_error.assert_not_called()
|
|
||||||
|
|
||||||
self.assertEqual(mock_sleep.call_count, 20)
|
|
||||||
mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20)
|
|
||||||
|
|
||||||
# failed fast restore
|
|
||||||
mock_create_op.reset_mock()
|
|
||||||
mock_get_status.reset_mock()
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_sleep.reset_mock()
|
|
||||||
mock_get_status.side_effect = None
|
|
||||||
mock_get_status.return_value = OperationStatus.FAILED
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
|
|
||||||
BarmanRecoverExitCode.RECOVERY_FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_create_op.assert_called_once()
|
|
||||||
mock_get_status.assert_called_once_with(BARMAN_SERVER, "some_id")
|
|
||||||
mock_log_info.assert_has_calls([
|
|
||||||
mock.call("Created the recovery operation with ID %s", "some_id"),
|
|
||||||
])
|
|
||||||
mock_log_error.assert_has_calls([
|
|
||||||
mock.call("Recovery operation failed."),
|
|
||||||
])
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
|
|
||||||
# failed slow restore
|
|
||||||
mock_create_op.reset_mock()
|
|
||||||
mock_get_status.reset_mock()
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_log_error.reset_mock()
|
|
||||||
mock_sleep.reset_mock()
|
|
||||||
mock_get_status.side_effect = [OperationStatus.IN_PROGRESS] * 20 + [OperationStatus.FAILED]
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
|
|
||||||
BarmanRecoverExitCode.RECOVERY_FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_create_op.assert_called_once()
|
|
||||||
|
|
||||||
self.assertEqual(mock_get_status.call_count, 21)
|
|
||||||
mock_get_status.assert_has_calls([mock.call(BARMAN_SERVER, "some_id")] * 21)
|
|
||||||
|
|
||||||
self.assertEqual(mock_log_info.call_count, 21)
|
|
||||||
mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")]
|
|
||||||
+ [mock.call("Recovery operation %s is still in progress", "some_id")] * 20)
|
|
||||||
|
|
||||||
mock_log_error.assert_has_calls([
|
|
||||||
mock.call("Recovery operation failed."),
|
|
||||||
])
|
|
||||||
|
|
||||||
self.assertEqual(mock_sleep.call_count, 20)
|
|
||||||
mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20)
|
|
||||||
|
|
||||||
# create retries exceeded
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_log_error.reset_mock()
|
|
||||||
mock_sleep.reset_mock()
|
|
||||||
mock_create_op.side_effect = RetriesExceeded()
|
|
||||||
mock_get_status.side_effect = None
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
|
|
||||||
BarmanRecoverExitCode.HTTP_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_log_info.assert_not_called()
|
|
||||||
mock_log_error.assert_called_once_with("An issue was faced while trying to create a recovery operation: %r",
|
|
||||||
mock_create_op.side_effect)
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
|
|
||||||
# get status retries exceeded
|
|
||||||
mock_create_op.reset_mock()
|
|
||||||
mock_create_op.side_effect = None
|
|
||||||
mock_log_error.reset_mock()
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_get_status.side_effect = RetriesExceeded
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_restore_backup(self.api, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT),
|
|
||||||
BarmanRecoverExitCode.HTTP_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id")
|
|
||||||
mock_log_error.assert_called_once_with("Maximum number of retries exceeded, exiting.")
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBarmanRecoverCli(unittest.TestCase):
|
|
||||||
|
|
||||||
@patch("patroni.scripts.barman.recover._restore_backup")
|
|
||||||
def test_run_barman_recover(self, mock_rb):
|
|
||||||
api = MagicMock()
|
|
||||||
args = MagicMock()
|
|
||||||
|
|
||||||
# successful execution
|
|
||||||
mock_rb.return_value = BarmanRecoverExitCode.RECOVERY_DONE
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
run_barman_recover(api, args),
|
|
||||||
BarmanRecoverExitCode.RECOVERY_DONE,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_rb.assert_called_once_with(api, args.barman_server, args.backup_id,
|
|
||||||
args.ssh_command, args.data_directory,
|
|
||||||
args.loop_wait)
|
|
||||||
|
|
||||||
# failed execution
|
|
||||||
mock_rb.reset_mock()
|
|
||||||
|
|
||||||
mock_rb.return_value = BarmanRecoverExitCode.RECOVERY_FAILED
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
run_barman_recover(api, args),
|
|
||||||
BarmanRecoverExitCode.RECOVERY_FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_rb.assert_called_once_with(api, args.barman_server, args.backup_id,
|
|
||||||
args.ssh_command, args.data_directory,
|
|
||||||
args.loop_wait)
|
|
||||||
|
|
||||||
|
|
||||||
# stuff from patroni.scripts.barman.config_switch
|
|
||||||
|
|
||||||
|
|
||||||
class TestBarmanConfigSwitch(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.api = MagicMock()
|
|
||||||
# Reset the mock as the same instance is used across tests
|
|
||||||
self.api._http.request.reset_mock()
|
|
||||||
self.api._http.request.side_effect = None
|
|
||||||
|
|
||||||
@patch("time.sleep")
|
|
||||||
@patch("logging.info")
|
|
||||||
@patch("logging.error")
|
|
||||||
def test__switch_config(self, mock_log_error, mock_log_info, mock_sleep):
|
|
||||||
mock_create_op = self.api.create_config_switch_operation
|
|
||||||
mock_get_status = self.api.get_operation_status
|
|
||||||
|
|
||||||
# successful fast config-switch
|
|
||||||
mock_create_op.return_value = "some_id"
|
|
||||||
mock_get_status.return_value = OperationStatus.DONE
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
|
|
||||||
BarmanConfigSwitchExitCode.CONFIG_SWITCH_DONE,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_create_op.assert_called_once_with(BARMAN_SERVER, BARMAN_MODEL, None)
|
|
||||||
mock_get_status.assert_called_once_with(BARMAN_SERVER, "some_id")
|
|
||||||
mock_log_info.assert_has_calls([
|
|
||||||
mock.call("Created the config switch operation with ID %s", "some_id"),
|
|
||||||
mock.call("Config switch operation finished successfully."),
|
|
||||||
])
|
|
||||||
mock_log_error.assert_not_called()
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
|
|
||||||
# successful slow config-switch
|
|
||||||
mock_create_op.reset_mock()
|
|
||||||
mock_get_status.reset_mock()
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_get_status.side_effect = [OperationStatus.IN_PROGRESS] * 20 + [OperationStatus.DONE]
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
|
|
||||||
BarmanConfigSwitchExitCode.CONFIG_SWITCH_DONE,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_create_op.assert_called_once_with(BARMAN_SERVER, BARMAN_MODEL, None)
|
|
||||||
|
|
||||||
self.assertEqual(mock_get_status.call_count, 21)
|
|
||||||
mock_get_status.assert_has_calls([mock.call(BARMAN_SERVER, "some_id")] * 21)
|
|
||||||
|
|
||||||
self.assertEqual(mock_log_info.call_count, 22)
|
|
||||||
mock_log_info.assert_has_calls([mock.call("Created the config switch operation with ID %s", "some_id")]
|
|
||||||
+ [mock.call("Config switch operation %s is still in progress", "some_id")] * 20
|
|
||||||
+ [mock.call("Config switch operation finished successfully.")])
|
|
||||||
|
|
||||||
mock_log_error.assert_not_called()
|
|
||||||
|
|
||||||
self.assertEqual(mock_sleep.call_count, 20)
|
|
||||||
mock_sleep.assert_has_calls([mock.call(5)] * 20)
|
|
||||||
|
|
||||||
# failed fast config-switch
|
|
||||||
mock_create_op.reset_mock()
|
|
||||||
mock_get_status.reset_mock()
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_sleep.reset_mock()
|
|
||||||
mock_get_status.side_effect = None
|
|
||||||
mock_get_status.return_value = OperationStatus.FAILED
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
|
|
||||||
BarmanConfigSwitchExitCode.CONFIG_SWITCH_FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_create_op.assert_called_once()
|
|
||||||
mock_get_status.assert_called_once_with(BARMAN_SERVER, "some_id")
|
|
||||||
mock_log_info.assert_called_once_with("Created the config switch operation with ID %s", "some_id")
|
|
||||||
mock_log_error.assert_called_once_with("Config switch operation failed.")
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
|
|
||||||
# failed slow config-switch
|
|
||||||
mock_create_op.reset_mock()
|
|
||||||
mock_get_status.reset_mock()
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_log_error.reset_mock()
|
|
||||||
mock_sleep.reset_mock()
|
|
||||||
mock_get_status.side_effect = [OperationStatus.IN_PROGRESS] * 20 + [OperationStatus.FAILED]
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
|
|
||||||
BarmanConfigSwitchExitCode.CONFIG_SWITCH_FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_create_op.assert_called_once()
|
|
||||||
|
|
||||||
self.assertEqual(mock_get_status.call_count, 21)
|
|
||||||
mock_get_status.assert_has_calls([mock.call(BARMAN_SERVER, "some_id")] * 21)
|
|
||||||
|
|
||||||
self.assertEqual(mock_log_info.call_count, 21)
|
|
||||||
mock_log_info.assert_has_calls([mock.call("Created the config switch operation with ID %s", "some_id")]
|
|
||||||
+ [mock.call("Config switch operation %s is still in progress", "some_id")] * 20)
|
|
||||||
|
|
||||||
mock_log_error.assert_called_once_with("Config switch operation failed.")
|
|
||||||
|
|
||||||
self.assertEqual(mock_sleep.call_count, 20)
|
|
||||||
mock_sleep.assert_has_calls([mock.call(5)] * 20)
|
|
||||||
|
|
||||||
# create retries exceeded
|
|
||||||
mock_log_info.reset_mock()
|
|
||||||
mock_log_error.reset_mock()
|
|
||||||
mock_sleep.reset_mock()
|
|
||||||
mock_create_op.side_effect = RetriesExceeded()
|
|
||||||
mock_get_status.side_effect = None
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
|
|
||||||
BarmanConfigSwitchExitCode.HTTP_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_log_info.assert_not_called()
|
|
||||||
mock_log_error.assert_called_once_with("An issue was faced while trying to create a config switch operation: "
|
|
||||||
"%r",
|
|
||||||
mock_create_op.side_effect)
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
|
|
||||||
# get status retries exceeded
|
|
||||||
mock_create_op.reset_mock()
|
|
||||||
mock_create_op.side_effect = None
|
|
||||||
mock_log_error.reset_mock()
|
|
||||||
mock_get_status.side_effect = RetriesExceeded
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
_switch_config(self.api, BARMAN_SERVER, BARMAN_MODEL, None),
|
|
||||||
BarmanConfigSwitchExitCode.HTTP_ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_log_info.assert_called_once_with("Created the config switch operation with ID %s", "some_id")
|
|
||||||
mock_log_error.assert_called_once_with("Maximum number of retries exceeded, exiting.")
|
|
||||||
mock_sleep.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBarmanConfigSwitchCli(unittest.TestCase):
|
|
||||||
|
|
||||||
def test__should_skip_switch(self):
|
|
||||||
args = MagicMock()
|
|
||||||
|
|
||||||
for role, switch_when, expected in [
|
|
||||||
("master", "promoted", False),
|
|
||||||
("master", "demoted", True),
|
|
||||||
("master", "always", False),
|
|
||||||
|
|
||||||
("primary", "promoted", False),
|
|
||||||
("primary", "demoted", True),
|
|
||||||
("primary", "always", False),
|
|
||||||
|
|
||||||
("promoted", "promoted", False),
|
|
||||||
("promoted", "demoted", True),
|
|
||||||
("promoted", "always", False),
|
|
||||||
|
|
||||||
("standby_leader", "promoted", True),
|
|
||||||
("standby_leader", "demoted", True),
|
|
||||||
("standby_leader", "always", False),
|
|
||||||
|
|
||||||
("replica", "promoted", True),
|
|
||||||
("replica", "demoted", False),
|
|
||||||
("replica", "always", False),
|
|
||||||
|
|
||||||
("demoted", "promoted", True),
|
|
||||||
("demoted", "demoted", False),
|
|
||||||
("demoted", "always", False),
|
|
||||||
]:
|
|
||||||
args.role = role
|
|
||||||
args.switch_when = switch_when
|
|
||||||
self.assertEqual(_should_skip_switch(args), expected)
|
|
||||||
|
|
||||||
@patch("patroni.scripts.barman.config_switch._should_skip_switch")
|
|
||||||
@patch("patroni.scripts.barman.config_switch._switch_config")
|
|
||||||
@patch("logging.error")
|
|
||||||
@patch("logging.info")
|
|
||||||
def test_run_barman_config_switch(self, mock_log_info, mock_log_error, mock_sc, mock_skip):
|
|
||||||
api = MagicMock()
|
|
||||||
args = MagicMock()
|
|
||||||
args.reset = None
|
|
||||||
|
|
||||||
# successful execution
|
|
||||||
mock_skip.return_value = False
|
|
||||||
mock_sc.return_value = BarmanConfigSwitchExitCode.CONFIG_SWITCH_DONE
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
run_barman_config_switch(api, args),
|
|
||||||
BarmanConfigSwitchExitCode.CONFIG_SWITCH_DONE,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_sc.assert_called_once_with(api, args.barman_server, args.barman_model,
|
|
||||||
args.reset)
|
|
||||||
|
|
||||||
# failed execution
|
|
||||||
mock_sc.reset_mock()
|
|
||||||
|
|
||||||
mock_sc.return_value = BarmanConfigSwitchExitCode.CONFIG_SWITCH_FAILED
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
run_barman_config_switch(api, args),
|
|
||||||
BarmanConfigSwitchExitCode.CONFIG_SWITCH_FAILED,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_sc.assert_called_once_with(api, args.barman_server, args.barman_model,
|
|
||||||
args.reset)
|
|
||||||
|
|
||||||
# skipped execution
|
|
||||||
mock_sc.reset_mock()
|
|
||||||
mock_skip.return_value = True
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
run_barman_config_switch(api, args),
|
|
||||||
BarmanConfigSwitchExitCode.CONFIG_SWITCH_SKIPPED
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_sc.assert_not_called()
|
|
||||||
mock_log_info.assert_called_once_with("Config switch operation was skipped (role=%s, "
|
|
||||||
"switch_when=%s).", args.role, args.switch_when)
|
|
||||||
mock_log_error.assert_not_called()
|
|
||||||
|
|
||||||
# invalid args -- sample 1
|
|
||||||
mock_skip.return_value = False
|
|
||||||
args = MagicMock()
|
|
||||||
args.barman_server = BARMAN_SERVER
|
|
||||||
args.barman_model = BARMAN_MODEL
|
|
||||||
args.reset = True
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
run_barman_config_switch(api, args),
|
|
||||||
BarmanConfigSwitchExitCode.INVALID_ARGS,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_log_error.assert_called_once_with("One, and only one among 'barman_model' ('%s') and 'reset' "
|
|
||||||
"('%s') should be given", BARMAN_MODEL, True)
|
|
||||||
api.assert_not_called()
|
|
||||||
|
|
||||||
# invalid args -- sample 2
|
|
||||||
args = MagicMock()
|
|
||||||
args.barman_server = BARMAN_SERVER
|
|
||||||
args.barman_model = None
|
|
||||||
args.reset = None
|
|
||||||
|
|
||||||
mock_log_error.reset_mock()
|
|
||||||
api.reset_mock()
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
run_barman_config_switch(api, args),
|
|
||||||
BarmanConfigSwitchExitCode.INVALID_ARGS,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_log_error.assert_called_once_with("One, and only one among 'barman_model' ('%s') and 'reset' "
|
|
||||||
"('%s') should be given", None, None)
|
|
||||||
api.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
# stuff from patroni.scripts.barman.cli
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain(unittest.TestCase):
|
|
||||||
|
|
||||||
@patch("patroni.scripts.barman.cli.PgBackupApi")
|
|
||||||
@patch("patroni.scripts.barman.cli.set_up_logging")
|
|
||||||
@patch("patroni.scripts.barman.cli.ArgumentParser")
|
|
||||||
def test_main(self, mock_arg_parse, mock_set_up_log, mock_api):
|
|
||||||
# sub-command specified
|
|
||||||
args = MagicMock()
|
|
||||||
args.func.return_value = 0
|
|
||||||
mock_arg_parse.return_value.parse_known_args.return_value = (args, None)
|
|
||||||
|
|
||||||
with self.assertRaises(SystemExit) as exc:
|
|
||||||
main()
|
|
||||||
|
|
||||||
mock_arg_parse.assert_called_once()
|
|
||||||
mock_set_up_log.assert_called_once_with(args.log_file)
|
|
||||||
mock_api.assert_called_once_with(args.api_url, args.cert_file,
|
|
||||||
args.key_file, args.retry_wait,
|
|
||||||
args.max_retries)
|
|
||||||
mock_arg_parse.return_value.print_help.assert_not_called()
|
|
||||||
args.func.assert_called_once_with(mock_api.return_value, args)
|
|
||||||
self.assertEqual(exc.exception.code, 0)
|
|
||||||
|
|
||||||
# Issue in the API
|
|
||||||
mock_arg_parse.reset_mock()
|
|
||||||
mock_set_up_log.reset_mock()
|
|
||||||
mock_api.reset_mock()
|
|
||||||
mock_api.side_effect = ApiNotOk()
|
|
||||||
|
|
||||||
with self.assertRaises(SystemExit) as exc:
|
|
||||||
main()
|
|
||||||
|
|
||||||
mock_arg_parse.assert_called_once()
|
|
||||||
mock_set_up_log.assert_called_once_with(args.log_file)
|
|
||||||
mock_api.assert_called_once_with(args.api_url, args.cert_file,
|
|
||||||
args.key_file, args.retry_wait,
|
|
||||||
args.max_retries)
|
|
||||||
mock_arg_parse.return_value.print_help.assert_not_called()
|
|
||||||
self.assertEqual(exc.exception.code, -2)
|
|
||||||
|
|
||||||
# sub-command not specified
|
|
||||||
mock_arg_parse.reset_mock()
|
|
||||||
mock_set_up_log.reset_mock()
|
|
||||||
mock_api.reset_mock()
|
|
||||||
delattr(args, "func")
|
|
||||||
mock_api.side_effect = None
|
|
||||||
|
|
||||||
with self.assertRaises(SystemExit) as exc:
|
|
||||||
main()
|
|
||||||
|
|
||||||
mock_arg_parse.assert_called_once()
|
|
||||||
mock_set_up_log.assert_called_once_with(args.log_file)
|
|
||||||
mock_api.assert_not_called()
|
|
||||||
mock_arg_parse.return_value.print_help.assert_called_once_with()
|
|
||||||
self.assertEqual(exc.exception.code, -1)
|
|
||||||
+2
-14
@@ -4,11 +4,10 @@ import sys
|
|||||||
from mock import Mock, PropertyMock, patch
|
from mock import Mock, PropertyMock, patch
|
||||||
|
|
||||||
from patroni.async_executor import CriticalTask
|
from patroni.async_executor import CriticalTask
|
||||||
from patroni.collections import CaseInsensitiveDict
|
|
||||||
from patroni.postgresql import Postgresql
|
from patroni.postgresql import Postgresql
|
||||||
from patroni.postgresql.bootstrap import Bootstrap
|
from patroni.postgresql.bootstrap import Bootstrap
|
||||||
from patroni.postgresql.cancellable import CancellableSubprocess
|
from patroni.postgresql.cancellable import CancellableSubprocess
|
||||||
from patroni.postgresql.config import ConfigHandler, get_param_diff
|
from patroni.postgresql.config import ConfigHandler
|
||||||
|
|
||||||
from . import psycopg_connect, BaseTestPostgresql, mock_available_gucs
|
from . import psycopg_connect, BaseTestPostgresql, mock_available_gucs
|
||||||
|
|
||||||
@@ -143,16 +142,6 @@ class TestBootstrap(BaseTestPostgresql):
|
|||||||
(), error_handler
|
(), error_handler
|
||||||
),
|
),
|
||||||
["--key=value with spaces"])
|
["--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(CancellableSubprocess, 'call', Mock())
|
||||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||||
@@ -246,8 +235,7 @@ class TestBootstrap(BaseTestPostgresql):
|
|||||||
self.assertTrue(task.result)
|
self.assertTrue(task.result)
|
||||||
|
|
||||||
self.b.bootstrap(config)
|
self.b.bootstrap(config)
|
||||||
with patch.object(Postgresql, 'pending_restart_reason',
|
with patch.object(Postgresql, 'pending_restart', PropertyMock(return_value=True)), \
|
||||||
PropertyMock(CaseInsensitiveDict({'max_connections': get_param_diff('200', '100')}))), \
|
|
||||||
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
|
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
|
||||||
self.b.post_bootstrap({}, task)
|
self.b.post_bootstrap({}, task)
|
||||||
mock_restart.assert_called_once()
|
mock_restart.assert_called_once()
|
||||||
|
|||||||
+23
-19
@@ -1,26 +1,26 @@
|
|||||||
import time
|
import time
|
||||||
from mock import Mock, patch, PropertyMock
|
from mock import Mock, patch, PropertyMock
|
||||||
from patroni.postgresql.mpp.citus import CitusHandler
|
from patroni.postgresql.citus import CitusHandler
|
||||||
from patroni.psycopg import ProgrammingError
|
from patroni.psycopg import ProgrammingError
|
||||||
|
|
||||||
from . import BaseTestPostgresql, MockCursor, psycopg_connect, SleepException
|
from . import BaseTestPostgresql, MockCursor, psycopg_connect, SleepException
|
||||||
from .test_ha import get_cluster_initialized_with_leader
|
from .test_ha import get_cluster_initialized_with_leader
|
||||||
|
|
||||||
|
|
||||||
@patch('patroni.postgresql.mpp.citus.Thread', Mock())
|
@patch('patroni.postgresql.citus.Thread', Mock())
|
||||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||||
class TestCitus(BaseTestPostgresql):
|
class TestCitus(BaseTestPostgresql):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestCitus, self).setUp()
|
super(TestCitus, self).setUp()
|
||||||
self.c = self.p.mpp_handler
|
self.c = self.p.citus_handler
|
||||||
self.cluster = get_cluster_initialized_with_leader()
|
self.cluster = get_cluster_initialized_with_leader()
|
||||||
self.cluster.workers[1] = self.cluster
|
self.cluster.workers[1] = self.cluster
|
||||||
|
|
||||||
@patch('time.time', Mock(side_effect=[100, 130, 160, 190, 220, 250, 280, 310, 340, 370]))
|
@patch('time.time', Mock(side_effect=[100, 130, 160, 190, 220, 250, 280, 310, 340, 370]))
|
||||||
@patch('patroni.postgresql.mpp.citus.logger.exception', Mock(side_effect=SleepException))
|
@patch('patroni.postgresql.citus.logger.exception', Mock(side_effect=SleepException))
|
||||||
@patch('patroni.postgresql.mpp.citus.logger.warning')
|
@patch('patroni.postgresql.citus.logger.warning')
|
||||||
@patch('patroni.postgresql.mpp.citus.PgDistNode.wait', Mock())
|
@patch('patroni.postgresql.citus.PgDistNode.wait', Mock())
|
||||||
@patch.object(CitusHandler, 'is_alive', Mock(return_value=True))
|
@patch.object(CitusHandler, 'is_alive', Mock(return_value=True))
|
||||||
def test_run(self, mock_logger_warning):
|
def test_run(self, mock_logger_warning):
|
||||||
# `before_demote` or `before_promote` REST API calls starting a
|
# `before_demote` or `before_promote` REST API calls starting a
|
||||||
@@ -40,10 +40,10 @@ class TestCitus(BaseTestPostgresql):
|
|||||||
|
|
||||||
@patch.object(CitusHandler, 'is_alive', Mock(return_value=False))
|
@patch.object(CitusHandler, 'is_alive', Mock(return_value=False))
|
||||||
@patch.object(CitusHandler, 'start', Mock())
|
@patch.object(CitusHandler, 'start', Mock())
|
||||||
def test_sync_meta_data(self):
|
def test_sync_pg_dist_node(self):
|
||||||
with patch.object(CitusHandler, 'is_enabled', Mock(return_value=False)):
|
with patch.object(CitusHandler, 'is_enabled', Mock(return_value=False)):
|
||||||
self.c.sync_meta_data(self.cluster)
|
self.c.sync_pg_dist_node(self.cluster)
|
||||||
self.c.sync_meta_data(self.cluster)
|
self.c.sync_pg_dist_node(self.cluster)
|
||||||
|
|
||||||
def test_handle_event(self):
|
def test_handle_event(self):
|
||||||
self.c.handle_event(self.cluster, {})
|
self.c.handle_event(self.cluster, {})
|
||||||
@@ -52,22 +52,22 @@ class TestCitus(BaseTestPostgresql):
|
|||||||
'leader': 'leader', 'timeout': 30, 'cooldown': 10})
|
'leader': 'leader', 'timeout': 30, 'cooldown': 10})
|
||||||
|
|
||||||
def test_add_task(self):
|
def test_add_task(self):
|
||||||
with patch('patroni.postgresql.mpp.citus.logger.error') as mock_logger, \
|
with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
|
||||||
patch('patroni.postgresql.mpp.citus.urlparse', Mock(side_effect=Exception)):
|
patch('patroni.postgresql.citus.urlparse', Mock(side_effect=Exception)):
|
||||||
self.c.add_task('', 1, None)
|
self.c.add_task('', 1, None)
|
||||||
mock_logger.assert_called_once()
|
mock_logger.assert_called_once()
|
||||||
|
|
||||||
with patch('patroni.postgresql.mpp.citus.logger.debug') as mock_logger:
|
with patch('patroni.postgresql.citus.logger.debug') as mock_logger:
|
||||||
self.c.add_task('before_demote', 1, 'postgres://host:5432/postgres', 30)
|
self.c.add_task('before_demote', 1, 'postgres://host:5432/postgres', 30)
|
||||||
mock_logger.assert_called_once()
|
mock_logger.assert_called_once()
|
||||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Adding the new task:'))
|
self.assertTrue(mock_logger.call_args[0][0].startswith('Adding the new task:'))
|
||||||
|
|
||||||
with patch('patroni.postgresql.mpp.citus.logger.debug') as mock_logger:
|
with patch('patroni.postgresql.citus.logger.debug') as mock_logger:
|
||||||
self.c.add_task('before_promote', 1, 'postgres://host:5432/postgres', 30)
|
self.c.add_task('before_promote', 1, 'postgres://host:5432/postgres', 30)
|
||||||
mock_logger.assert_called_once()
|
mock_logger.assert_called_once()
|
||||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Overriding existing task:'))
|
self.assertTrue(mock_logger.call_args[0][0].startswith('Overriding existing task:'))
|
||||||
|
|
||||||
# add_task called from sync_meta_data should not override already scheduled or in flight task until deadline
|
# add_task called from sync_pg_dist_node should not override already scheduled or in flight task until deadline
|
||||||
self.assertIsNotNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres', 30))
|
self.assertIsNotNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres', 30))
|
||||||
self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres'))
|
self.assertIsNone(self.c.add_task('after_promote', 1, 'postgres://host:5432/postgres'))
|
||||||
self.c._in_flight = self.c._tasks.pop()
|
self.c._in_flight = self.c._tasks.pop()
|
||||||
@@ -107,7 +107,7 @@ class TestCitus(BaseTestPostgresql):
|
|||||||
self.c.process_tasks()
|
self.c.process_tasks()
|
||||||
|
|
||||||
self.c.add_task('after_promote', 0, 'postgres://host3:5432/postgres')
|
self.c.add_task('after_promote', 0, 'postgres://host3:5432/postgres')
|
||||||
with patch('patroni.postgresql.mpp.citus.logger.error') as mock_logger, \
|
with patch('patroni.postgresql.citus.logger.error') as mock_logger, \
|
||||||
patch.object(CitusHandler, 'query', Mock(side_effect=Exception)):
|
patch.object(CitusHandler, 'query', Mock(side_effect=Exception)):
|
||||||
self.c.process_tasks()
|
self.c.process_tasks()
|
||||||
mock_logger.assert_called_once()
|
mock_logger.assert_called_once()
|
||||||
@@ -116,7 +116,7 @@ class TestCitus(BaseTestPostgresql):
|
|||||||
def test_on_demote(self):
|
def test_on_demote(self):
|
||||||
self.c.on_demote()
|
self.c.on_demote()
|
||||||
|
|
||||||
@patch('patroni.postgresql.mpp.citus.logger.error')
|
@patch('patroni.postgresql.citus.logger.error')
|
||||||
@patch.object(MockCursor, 'execute', Mock(side_effect=Exception))
|
@patch.object(MockCursor, 'execute', Mock(side_effect=Exception))
|
||||||
def test_load_pg_dist_node(self, mock_logger):
|
def test_load_pg_dist_node(self, mock_logger):
|
||||||
# load_pg_dist_node() triggers, query fails and exception is property handled
|
# load_pg_dist_node() triggers, query fails and exception is property handled
|
||||||
@@ -141,6 +141,10 @@ class TestCitus(BaseTestPostgresql):
|
|||||||
self.assertEqual(parameters['wal_level'], 'logical')
|
self.assertEqual(parameters['wal_level'], 'logical')
|
||||||
self.assertEqual(parameters['citus.local_hostname'], '/tmp')
|
self.assertEqual(parameters['citus.local_hostname'], '/tmp')
|
||||||
|
|
||||||
|
def test_bootstrap(self):
|
||||||
|
self.c._config = None
|
||||||
|
self.c.bootstrap()
|
||||||
|
|
||||||
def test_ignore_replication_slot(self):
|
def test_ignore_replication_slot(self):
|
||||||
self.assertFalse(self.c.ignore_replication_slot({'name': 'foo', 'type': 'physical',
|
self.assertFalse(self.c.ignore_replication_slot({'name': 'foo', 'type': 'physical',
|
||||||
'database': 'bar', 'plugin': 'wal2json'}))
|
'database': 'bar', 'plugin': 'wal2json'}))
|
||||||
@@ -159,9 +163,9 @@ class TestCitus(BaseTestPostgresql):
|
|||||||
self.assertTrue(self.c.ignore_replication_slot({'name': 'citus_shard_split_slot_1_2_3',
|
self.assertTrue(self.c.ignore_replication_slot({'name': 'citus_shard_split_slot_1_2_3',
|
||||||
'type': 'logical', 'database': 'citus', 'plugin': 'citus'}))
|
'type': 'logical', 'database': 'citus', 'plugin': 'citus'}))
|
||||||
|
|
||||||
@patch('patroni.postgresql.mpp.citus.logger.debug')
|
@patch('patroni.postgresql.citus.logger.debug')
|
||||||
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
|
@patch('patroni.postgresql.citus.connect', psycopg_connect)
|
||||||
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
|
@patch('patroni.postgresql.citus.quote_ident', Mock())
|
||||||
def test_bootstrap_duplicate_database(self, mock_logger):
|
def test_bootstrap_duplicate_database(self, mock_logger):
|
||||||
with patch.object(MockCursor, 'execute', Mock(side_effect=ProgrammingError)):
|
with patch.object(MockCursor, 'execute', Mock(side_effect=ProgrammingError)):
|
||||||
self.assertRaises(ProgrammingError, self.c.bootstrap)
|
self.assertRaises(ProgrammingError, self.c.bootstrap)
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import io
|
|||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from mock import MagicMock, Mock, patch
|
from mock import MagicMock, Mock, patch
|
||||||
|
from patroni.config import Config, ConfigParseError, GlobalConfig
|
||||||
from patroni import global_config
|
|
||||||
from patroni.config import ClusterConfig, Config, ConfigParseError
|
|
||||||
|
|
||||||
from .test_ha import get_cluster_initialized_with_only_leader
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfig(unittest.TestCase):
|
class TestConfig(unittest.TestCase):
|
||||||
@@ -35,7 +31,6 @@ class TestConfig(unittest.TestCase):
|
|||||||
'PATRONI_NAMESPACE': '/patroni/',
|
'PATRONI_NAMESPACE': '/patroni/',
|
||||||
'PATRONI_SCOPE': 'batman2',
|
'PATRONI_SCOPE': 'batman2',
|
||||||
'PATRONI_LOGLEVEL': 'ERROR',
|
'PATRONI_LOGLEVEL': 'ERROR',
|
||||||
'PATRONI_LOG_FORMAT': '["message", {"levelname": "level"}]',
|
|
||||||
'PATRONI_LOG_LOGGERS': 'patroni.postmaster: WARNING, urllib3: DEBUG',
|
'PATRONI_LOG_LOGGERS': 'patroni.postmaster: WARNING, urllib3: DEBUG',
|
||||||
'PATRONI_LOG_FILE_NUM': '5',
|
'PATRONI_LOG_FILE_NUM': '5',
|
||||||
'PATRONI_CITUS_DATABASE': 'citus',
|
'PATRONI_CITUS_DATABASE': 'citus',
|
||||||
@@ -245,6 +240,4 @@ class TestConfig(unittest.TestCase):
|
|||||||
def test_global_config_is_synchronous_mode(self):
|
def test_global_config_is_synchronous_mode(self):
|
||||||
# we should ignore synchronous_mode setting in a standby cluster
|
# we should ignore synchronous_mode setting in a standby cluster
|
||||||
config = {'standby_cluster': {'host': 'some_host'}, 'synchronous_mode': True}
|
config = {'standby_cluster': {'host': 'some_host'}, 'synchronous_mode': True}
|
||||||
cluster = get_cluster_initialized_with_only_leader(cluster_config=ClusterConfig(1, config, 1))
|
self.assertFalse(GlobalConfig(config).is_synchronous_mode)
|
||||||
test_config = global_config.from_cluster(cluster)
|
|
||||||
self.assertFalse(test_config.is_synchronous_mode)
|
|
||||||
|
|||||||
@@ -62,10 +62,9 @@ class TestGenerateConfig(unittest.TestCase):
|
|||||||
'scope': self.environ['PATRONI_SCOPE'],
|
'scope': self.environ['PATRONI_SCOPE'],
|
||||||
'name': HOSTNAME,
|
'name': HOSTNAME,
|
||||||
'log': {
|
'log': {
|
||||||
'type': PatroniLogger.DEFAULT_TYPE,
|
|
||||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
|
||||||
'level': PatroniLogger.DEFAULT_LEVEL,
|
'level': PatroniLogger.DEFAULT_LEVEL,
|
||||||
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
||||||
|
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||||
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
|
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
|
||||||
},
|
},
|
||||||
'restapi': {
|
'restapi': {
|
||||||
@@ -142,7 +141,6 @@ class TestGenerateConfig(unittest.TestCase):
|
|||||||
'noloadbalance': False,
|
'noloadbalance': False,
|
||||||
'clonefrom': True,
|
'clonefrom': True,
|
||||||
'nosync': False,
|
'nosync': False,
|
||||||
'nostream': False
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
patch_config(self.config, conf)
|
patch_config(self.config, conf)
|
||||||
|
|||||||
+12
-16
@@ -3,10 +3,8 @@ import unittest
|
|||||||
|
|
||||||
from consul import ConsulException, NotFound
|
from consul import ConsulException, NotFound
|
||||||
from mock import Mock, PropertyMock, patch
|
from mock import Mock, PropertyMock, patch
|
||||||
from patroni.dcs import get_dcs
|
|
||||||
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
|
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
|
||||||
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession, RetryFailedError
|
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession, RetryFailedError
|
||||||
from patroni.postgresql.mpp import get_mpp
|
|
||||||
from . import SleepException
|
from . import SleepException
|
||||||
|
|
||||||
|
|
||||||
@@ -93,17 +91,13 @@ class TestConsul(unittest.TestCase):
|
|||||||
@patch.object(consul.Consul.KV, 'get', kv_get)
|
@patch.object(consul.Consul.KV, 'get', kv_get)
|
||||||
@patch.object(consul.Consul.KV, 'delete', Mock())
|
@patch.object(consul.Consul.KV, 'delete', Mock())
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 't', 'name': 'p', 'retry_timeout': 10,
|
Consul({'ttl': 30, 'scope': 't', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
||||||
'consul': {'url': 'https://l:1', 'verify': 'on',
|
'verify': 'on', 'key': 'foo', 'cert': 'bar', 'cacert': 'buz', 'token': 'asd', 'dc': 'dc1',
|
||||||
'key': 'foo', 'cert': 'bar', 'cacert': 'buz',
|
'register_service': True})
|
||||||
'token': 'asd', 'dc': 'dc1', 'register_service': True}}), Consul)
|
Consul({'ttl': 30, 'scope': 't_', 'name': 'p', 'url': 'https://l:1', 'retry_timeout': 10,
|
||||||
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 't_', 'name': 'p', 'retry_timeout': 10,
|
'verify': 'on', 'cert': 'bar', 'cacert': 'buz', 'register_service': True})
|
||||||
'consul': {'url': 'https://l:1', 'verify': 'on',
|
self.c = Consul({'ttl': 30, 'scope': 'test', 'name': 'postgresql1', 'host': 'localhost:1', 'retry_timeout': 10,
|
||||||
'cert': 'bar', 'cacert': 'buz', 'register_service': True}}), Consul)
|
'register_service': True, 'service_check_tls_server_name': True})
|
||||||
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._base_path = 'service/good'
|
||||||
self.c.get_cluster()
|
self.c.get_cluster()
|
||||||
|
|
||||||
@@ -136,7 +130,7 @@ class TestConsul(unittest.TestCase):
|
|||||||
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
self.assertIsInstance(self.c.get_cluster(), Cluster)
|
||||||
|
|
||||||
def test__get_citus_cluster(self):
|
def test__get_citus_cluster(self):
|
||||||
self.c._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
self.c._citus_group = '0'
|
||||||
cluster = self.c.get_cluster()
|
cluster = self.c.get_cluster()
|
||||||
self.assertIsInstance(cluster, Cluster)
|
self.assertIsInstance(cluster, Cluster)
|
||||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||||
@@ -160,8 +154,10 @@ class TestConsul(unittest.TestCase):
|
|||||||
self.c.set_ttl(20)
|
self.c.set_ttl(20)
|
||||||
self.c._do_refresh_session = Mock()
|
self.c._do_refresh_session = Mock()
|
||||||
self.assertFalse(self.c.take_leader())
|
self.assertFalse(self.c.take_leader())
|
||||||
with patch('time.time', Mock(side_effect=[0, 0, 0, 100, 100])):
|
with patch('time.time', Mock(side_effect=[0, 100])):
|
||||||
self.assertFalse(self.c.take_leader())
|
self.assertRaises(ConsulError, self.c.take_leader)
|
||||||
|
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 0, 100])):
|
||||||
|
self.assertRaises(ConsulError, self.c.take_leader)
|
||||||
|
|
||||||
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
@patch.object(consul.Consul.KV, 'put', Mock(return_value=True))
|
||||||
def test_set_failover_value(self):
|
def test_set_failover_value(self):
|
||||||
|
|||||||
+172
-134
@@ -1,4 +1,3 @@
|
|||||||
import click
|
|
||||||
import etcd
|
import etcd
|
||||||
import mock
|
import mock
|
||||||
import os
|
import os
|
||||||
@@ -7,13 +6,10 @@ import unittest
|
|||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from mock import patch, Mock, PropertyMock
|
from mock import patch, Mock, PropertyMock
|
||||||
from patroni import global_config
|
|
||||||
from patroni.ctl import ctl, load_config, output_members, get_dcs, parse_dcs, \
|
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, \
|
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
|
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH, PatronictlPrettyTable
|
||||||
from patroni.dcs import Cluster, Failover
|
from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Cluster, Failover
|
||||||
from patroni.postgresql.config import get_param_diff
|
|
||||||
from patroni.postgresql.mpp import get_mpp
|
|
||||||
from patroni.psycopg import OperationalError
|
from patroni.psycopg import OperationalError
|
||||||
from patroni.utils import tzutc
|
from patroni.utils import tzutc
|
||||||
from prettytable import PrettyTable, ALL
|
from prettytable import PrettyTable, ALL
|
||||||
@@ -25,26 +21,26 @@ from .test_ha import get_cluster_initialized_without_leader, get_cluster_initial
|
|||||||
get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader, get_cluster, Member
|
get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader, get_cluster, Member
|
||||||
|
|
||||||
|
|
||||||
def get_default_config(*args):
|
DEFAULT_CONFIG = {
|
||||||
return {
|
|
||||||
'scope': 'alpha',
|
'scope': 'alpha',
|
||||||
'restapi': {'listen': '::', 'certfile': 'a'},
|
'restapi': {'listen': '::', 'certfile': 'a'},
|
||||||
'ctl': {'certfile': 'a'},
|
'ctl': {'certfile': 'a'},
|
||||||
'etcd': {'host': 'localhost:2379', 'retry_timeout': 10, 'ttl': 30},
|
'etcd': {'host': 'localhost:2379'},
|
||||||
'citus': {'database': 'citus', 'group': 0},
|
'citus': {'database': 'citus', 'group': 0},
|
||||||
'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}
|
'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
|
@patch('patroni.ctl.load_config', Mock(return_value=DEFAULT_CONFIG))
|
||||||
@patch('patroni.ctl.load_config', get_default_config)
|
|
||||||
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
|
||||||
class TestCtl(unittest.TestCase):
|
class TestCtl(unittest.TestCase):
|
||||||
TEST_ROLES = ('master', 'primary', 'leader')
|
TEST_ROLES = ('master', 'primary', 'leader')
|
||||||
|
|
||||||
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||||
|
@patch.object(AbstractEtcdClientWithFailover, '_get_machines_list', Mock(return_value=['http://remotehost:2379']))
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.runner = CliRunner()
|
self.runner = CliRunner()
|
||||||
|
self.e = get_dcs({'etcd': {'ttl': 30, 'host': 'ok:2379', 'retry_timeout': 10},
|
||||||
|
'citus': {'group': 0}}, 'foo', None)
|
||||||
|
|
||||||
@patch('patroni.ctl.logging.debug')
|
@patch('patroni.ctl.logging.debug')
|
||||||
def test_load_config(self, mock_logger_debug):
|
def test_load_config(self, mock_logger_debug):
|
||||||
@@ -70,30 +66,28 @@ class TestCtl(unittest.TestCase):
|
|||||||
|
|
||||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||||
def test_get_cursor(self):
|
def test_get_cursor(self):
|
||||||
with click.Context(click.Command('query')) as ctx:
|
|
||||||
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
|
|
||||||
for role in self.TEST_ROLES:
|
for role in self.TEST_ROLES:
|
||||||
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), None, {}, role=role))
|
self.assertIsNone(get_cursor({}, get_cluster_initialized_without_leader(), None, {}, role=role))
|
||||||
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {}, role=role))
|
self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {}, role=role))
|
||||||
|
|
||||||
# MockCursor returns pg_is_in_recovery as false
|
# MockCursor returns pg_is_in_recovery as false
|
||||||
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), None, {}, role='replica'))
|
self.assertIsNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {}, role='replica'))
|
||||||
|
|
||||||
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, role='any'))
|
self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, role='any'))
|
||||||
|
|
||||||
# Mutually exclusive options
|
# Mutually exclusive options
|
||||||
with self.assertRaises(PatroniCtlException) as e:
|
with self.assertRaises(PatroniCtlException) as e:
|
||||||
get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, member_name='other',
|
get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, member_name='other',
|
||||||
role='replica')
|
role='replica')
|
||||||
|
|
||||||
self.assertEqual(str(e.exception), '--role and --member are mutually exclusive options')
|
self.assertEqual(str(e.exception), '--role and --member are mutually exclusive options')
|
||||||
|
|
||||||
# Invalid member provided
|
# Invalid member provided
|
||||||
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
|
self.assertIsNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
|
||||||
member_name='invalid'))
|
member_name='invalid'))
|
||||||
|
|
||||||
# Valid member provided
|
# Valid member provided
|
||||||
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
|
self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
|
||||||
member_name='other'))
|
member_name='other'))
|
||||||
|
|
||||||
def test_parse_dcs(self):
|
def test_parse_dcs(self):
|
||||||
@@ -108,20 +102,23 @@ class TestCtl(unittest.TestCase):
|
|||||||
self.assertRaises(PatroniCtlException, parse_dcs, 'invalid://test')
|
self.assertRaises(PatroniCtlException, parse_dcs, 'invalid://test')
|
||||||
|
|
||||||
def test_output_members(self):
|
def test_output_members(self):
|
||||||
with click.Context(click.Command('list')) as ctx:
|
|
||||||
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
|
|
||||||
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
|
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
|
||||||
cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at))
|
cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at))
|
||||||
del cluster.members[1].data['conn_url']
|
del cluster.members[1].data['conn_url']
|
||||||
for fmt in ('pretty', 'json', 'yaml', 'topology'):
|
for fmt in ('pretty', 'json', 'yaml', 'topology'):
|
||||||
self.assertIsNone(output_members(cluster, name='abc', fmt=fmt))
|
self.assertIsNone(output_members({}, cluster, name='abc', fmt=fmt))
|
||||||
|
|
||||||
with patch('click.echo') as mock_echo:
|
with patch('click.echo') as mock_echo:
|
||||||
self.assertIsNone(output_members(cluster, name='abc', fmt='tsv'))
|
self.assertIsNone(output_members({}, cluster, name='abc', fmt='tsv'))
|
||||||
self.assertEqual(mock_echo.call_args[0][0], 'abc\tother\t\tReplica\trunning\t\tunknown')
|
self.assertEqual(mock_echo.call_args[0][0], 'abc\tother\t\tReplica\trunning\t\tunknown')
|
||||||
|
|
||||||
@patch('patroni.dcs.AbstractDCS.set_failover_value', Mock())
|
@patch('patroni.ctl.get_dcs')
|
||||||
def test_switchover(self):
|
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
|
||||||
|
def test_switchover(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
mock_get_dcs.return_value.set_failover_value = Mock()
|
||||||
|
|
||||||
# Confirm
|
# Confirm
|
||||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
||||||
self.assertEqual(result.exit_code, 0)
|
self.assertEqual(result.exit_code, 0)
|
||||||
@@ -150,7 +147,7 @@ class TestCtl(unittest.TestCase):
|
|||||||
self.assertEqual(result.exit_code, 0)
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
|
||||||
# Scheduled in pause mode
|
# Scheduled in pause mode
|
||||||
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
|
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
|
||||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||||
'--force', '--scheduled', '2015-01-01T12:00:00'])
|
'--force', '--scheduled', '2015-01-01T12:00:00'])
|
||||||
self.assertEqual(result.exit_code, 1)
|
self.assertEqual(result.exit_code, 1)
|
||||||
@@ -184,12 +181,12 @@ class TestCtl(unittest.TestCase):
|
|||||||
self.assertIn('Member dummy is not the leader of cluster dummy', result.output)
|
self.assertIn('Member dummy is not the leader of cluster dummy', result.output)
|
||||||
|
|
||||||
# Errors while sending Patroni REST API request
|
# Errors while sending Patroni REST API request
|
||||||
with patch('patroni.ctl.request_patroni', Mock(side_effect=Exception)):
|
with patch.object(PoolManager, 'request', Mock(side_effect=Exception)):
|
||||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'],
|
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'],
|
||||||
input='leader\nother\n2300-01-01T12:23:00\ny')
|
input='leader\nother\n2300-01-01T12:23:00\ny')
|
||||||
self.assertIn('falling back to DCS', result.output)
|
self.assertIn('falling back to DCS', result.output)
|
||||||
|
|
||||||
with patch('patroni.ctl.request_patroni') as mock_api_request:
|
with patch.object(PoolManager, 'request') as mock_api_request:
|
||||||
mock_api_request.return_value.status = 500
|
mock_api_request.return_value.status = 500
|
||||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
||||||
self.assertIn('Switchover failed', result.output)
|
self.assertIn('Switchover failed', result.output)
|
||||||
@@ -200,14 +197,13 @@ class TestCtl(unittest.TestCase):
|
|||||||
self.assertIn('Switchover failed', result.output)
|
self.assertIn('Switchover failed', result.output)
|
||||||
|
|
||||||
# No members available
|
# No members available
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster',
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_only_leader
|
||||||
Mock(return_value=get_cluster_initialized_with_only_leader())):
|
|
||||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
||||||
self.assertEqual(result.exit_code, 1)
|
self.assertEqual(result.exit_code, 1)
|
||||||
self.assertIn('No candidates found to switchover to', result.output)
|
self.assertIn('No candidates found to switchover to', result.output)
|
||||||
|
|
||||||
# No leader available
|
# No leader available
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())):
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_without_leader
|
||||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
||||||
self.assertEqual(result.exit_code, 1)
|
self.assertEqual(result.exit_code, 1)
|
||||||
self.assertIn('This cluster has no leader', result.output)
|
self.assertIn('This cluster has no leader', result.output)
|
||||||
@@ -217,9 +213,14 @@ class TestCtl(unittest.TestCase):
|
|||||||
self.assertEqual(result.exit_code, 1)
|
self.assertEqual(result.exit_code, 1)
|
||||||
self.assertIn('For Citus clusters the --group must me specified', result.output)
|
self.assertIn('For Citus clusters the --group must me specified', result.output)
|
||||||
|
|
||||||
@patch('patroni.dcs.AbstractDCS.set_failover_value', Mock())
|
@patch('patroni.ctl.get_dcs')
|
||||||
def test_failover(self):
|
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
|
||||||
|
@patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse()))
|
||||||
|
def test_failover(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value.set_failover_value = Mock()
|
||||||
|
|
||||||
# No candidate specified
|
# No candidate specified
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='0\n')
|
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='0\n')
|
||||||
self.assertIn('Failover could be performed only to a specific candidate', result.output)
|
self.assertIn('Failover could be performed only to a specific candidate', result.output)
|
||||||
|
|
||||||
@@ -232,12 +233,13 @@ class TestCtl(unittest.TestCase):
|
|||||||
with patch('patroni.ctl._do_failover_or_switchover') as failover_func_mock:
|
with patch('patroni.ctl._do_failover_or_switchover') as failover_func_mock:
|
||||||
result = self.runner.invoke(ctl, ['failover', '--leader', 'leader', 'dummy'], input='0\n')
|
result = self.runner.invoke(ctl, ['failover', '--leader', 'leader', 'dummy'], input='0\n')
|
||||||
self.assertIn('Supplying a leader name using this command is deprecated', result.output)
|
self.assertIn('Supplying a leader name using this command is deprecated', result.output)
|
||||||
failover_func_mock.assert_called_once_with('switchover', 'dummy', None, 'leader', None, False)
|
failover_func_mock.assert_called_once_with(
|
||||||
|
DEFAULT_CONFIG, 'switchover', 'dummy', None, 'leader', None, False)
|
||||||
|
|
||||||
cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||||
cluster.members.append(Member(0, 'async', 28, {'api_url': 'http://127.0.0.1:8012/patroni'}))
|
cluster.members.append(Member(0, 'async', 28, {'api_url': 'http://127.0.0.1:8012/patroni'}))
|
||||||
cluster.config.data['synchronous_mode'] = True
|
cluster.config.data['synchronous_mode'] = True
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)):
|
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
|
||||||
# Failover to an async member in sync mode (confirm)
|
# Failover to an async member in sync mode (confirm)
|
||||||
result = self.runner.invoke(ctl,
|
result = self.runner.invoke(ctl,
|
||||||
['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='y\ny')
|
['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='y\ny')
|
||||||
@@ -249,16 +251,16 @@ class TestCtl(unittest.TestCase):
|
|||||||
self.assertEqual(result.exit_code, 1)
|
self.assertEqual(result.exit_code, 1)
|
||||||
self.assertIn('Aborting failover', result.output)
|
self.assertIn('Aborting failover', result.output)
|
||||||
|
|
||||||
@patch('patroni.dynamic_loader.iter_modules', Mock(return_value=['patroni.dcs.dummy', 'patroni.dcs.etcd']))
|
@patch('patroni.dcs.dcs_modules', Mock(return_value=['patroni.dcs.dummy', 'patroni.dcs.etcd']))
|
||||||
def test_get_dcs(self):
|
def test_get_dcs(self):
|
||||||
with click.Context(click.Command('list')) as ctx:
|
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy', 0)
|
||||||
ctx.obj = {'__config': {'dummy': {}}, '__mpp': get_mpp({})}
|
|
||||||
self.assertRaises(PatroniCtlException, get_dcs, 'dummy', 0)
|
|
||||||
|
|
||||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||||
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
|
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
|
||||||
|
@patch('patroni.ctl.get_dcs')
|
||||||
@patch.object(etcd.Client, 'read', etcd_read)
|
@patch.object(etcd.Client, 'read', etcd_read)
|
||||||
def test_query(self):
|
def test_query(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
# Mutually exclusive
|
# Mutually exclusive
|
||||||
for role in self.TEST_ROLES:
|
for role in self.TEST_ROLES:
|
||||||
result = self.runner.invoke(ctl, ['query', 'alpha', '--member', 'abc', '--role', role])
|
result = self.runner.invoke(ctl, ['query', 'alpha', '--member', 'abc', '--role', role])
|
||||||
@@ -291,29 +293,31 @@ class TestCtl(unittest.TestCase):
|
|||||||
def test_query_member(self):
|
def test_query_member(self):
|
||||||
with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())):
|
with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())):
|
||||||
for role in self.TEST_ROLES:
|
for role in self.TEST_ROLES:
|
||||||
rows = query_member(None, None, None, None, role, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
rows = query_member({}, None, None, None, None, role, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
self.assertTrue('False' in str(rows))
|
self.assertTrue('False' in str(rows))
|
||||||
|
|
||||||
with patch.object(MockCursor, 'execute', Mock(side_effect=OperationalError('bla'))):
|
with patch.object(MockCursor, 'execute', Mock(side_effect=OperationalError('bla'))):
|
||||||
rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
|
|
||||||
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
|
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
|
||||||
# No role nor member given -- generic message
|
# No role nor member given -- generic message
|
||||||
rows = query_member(None, None, None, None, None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
rows = query_member({}, None, None, None, None, None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
self.assertTrue('No connection is available' in str(rows))
|
self.assertTrue('No connection is available' in str(rows))
|
||||||
|
|
||||||
# Member given -- message pointing to member
|
# Member given -- message pointing to member
|
||||||
rows = query_member(None, None, None, 'foo', None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
rows = query_member({}, None, None, None, 'foo', None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
self.assertTrue('No connection to member foo' in str(rows))
|
self.assertTrue('No connection to member foo' in str(rows))
|
||||||
|
|
||||||
# Role given -- message pointing to role
|
# Role given -- message pointing to role
|
||||||
rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
self.assertTrue('No connection to role replica' in str(rows))
|
self.assertTrue('No connection to role replica' in str(rows))
|
||||||
|
|
||||||
with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))):
|
with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))):
|
||||||
rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||||
|
|
||||||
def test_dsn(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_dsn(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
result = self.runner.invoke(ctl, ['dsn', 'alpha'])
|
result = self.runner.invoke(ctl, ['dsn', 'alpha'])
|
||||||
assert 'host=127.0.0.1 port=5435' in result.output
|
assert 'host=127.0.0.1 port=5435' in result.output
|
||||||
|
|
||||||
@@ -326,8 +330,11 @@ class TestCtl(unittest.TestCase):
|
|||||||
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
|
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
|
|
||||||
@patch('patroni.ctl.request_patroni')
|
@patch.object(PoolManager, 'request')
|
||||||
def test_reload(self, mock_post):
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_reload(self, mock_get_dcs, mock_post):
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
|
||||||
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
|
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
|
||||||
assert 'Failed: reload for member' in result.output
|
assert 'Failed: reload for member' in result.output
|
||||||
|
|
||||||
@@ -339,8 +346,10 @@ class TestCtl(unittest.TestCase):
|
|||||||
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
|
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
|
||||||
assert 'Reload request received for member' in result.output
|
assert 'Reload request received for member' in result.output
|
||||||
|
|
||||||
@patch('patroni.ctl.request_patroni')
|
@patch.object(PoolManager, 'request')
|
||||||
def test_restart_reinit(self, mock_post):
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_restart_reinit(self, mock_get_dcs, mock_post):
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
mock_post.return_value.status = 503
|
mock_post.return_value.status = 503
|
||||||
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='now\ny\n')
|
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='now\ny\n')
|
||||||
assert 'Failed: restart for' in result.output
|
assert 'Failed: restart for' in result.output
|
||||||
@@ -380,7 +389,7 @@ class TestCtl(unittest.TestCase):
|
|||||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
|
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
|
||||||
assert 'Failed: flush scheduled restart' in result.output
|
assert 'Failed: flush scheduled restart' in result.output
|
||||||
|
|
||||||
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
|
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
|
||||||
result = self.runner.invoke(ctl,
|
result = self.runner.invoke(ctl,
|
||||||
['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
|
['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
@@ -415,10 +424,12 @@ class TestCtl(unittest.TestCase):
|
|||||||
assert 'Failed: another restart is already' in result.output
|
assert 'Failed: another restart is already' in result.output
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
def test_remove(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_remove(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
result = self.runner.invoke(ctl, ['remove', 'dummy'], input='\n')
|
result = self.runner.invoke(ctl, ['remove', 'dummy'], input='\n')
|
||||||
assert 'For Citus clusters the --group must me specified' in result.output
|
assert 'For Citus clusters the --group must me specified' in result.output
|
||||||
result = self.runner.invoke(ctl, ['remove', 'alpha', '--group', '0'], input='alpha\nstandby')
|
result = self.runner.invoke(ctl, ['-k', 'remove', 'alpha', '--group', '0'], input='alpha\nstandby')
|
||||||
assert 'Please confirm' in result.output
|
assert 'Please confirm' in result.output
|
||||||
assert 'You are about to remove all' in result.output
|
assert 'You are about to remove all' in result.output
|
||||||
# Not typing an exact confirmation
|
# Not typing an exact confirmation
|
||||||
@@ -436,36 +447,37 @@ class TestCtl(unittest.TestCase):
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
def test_ctl(self):
|
def test_ctl(self):
|
||||||
|
self.runner.invoke(ctl, ['list'])
|
||||||
|
|
||||||
result = self.runner.invoke(ctl, ['--help'])
|
result = self.runner.invoke(ctl, ['--help'])
|
||||||
assert 'Usage:' in result.output
|
assert 'Usage:' in result.output
|
||||||
|
|
||||||
def test_get_any_member(self):
|
def test_get_any_member(self):
|
||||||
with click.Context(click.Command('list')) as ctx:
|
|
||||||
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
|
|
||||||
for role in self.TEST_ROLES:
|
for role in self.TEST_ROLES:
|
||||||
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), None, role=role))
|
self.assertIsNone(get_any_member({}, get_cluster_initialized_without_leader(), None, role=role))
|
||||||
|
|
||||||
m = get_any_member(get_cluster_initialized_with_leader(), None, role=role)
|
m = get_any_member({}, get_cluster_initialized_with_leader(), None, role=role)
|
||||||
self.assertEqual(m.name, 'leader')
|
self.assertEqual(m.name, 'leader')
|
||||||
|
|
||||||
def test_get_all_members(self):
|
def test_get_all_members(self):
|
||||||
with click.Context(click.Command('list')) as ctx:
|
|
||||||
ctx.obj = {'__config': {}, '__mpp': get_mpp({})}
|
|
||||||
for role in self.TEST_ROLES:
|
for role in self.TEST_ROLES:
|
||||||
self.assertEqual(list(get_all_members(get_cluster_initialized_without_leader(), None, role=role)), [])
|
self.assertEqual(list(get_all_members({}, get_cluster_initialized_without_leader(), None, role=role)), [])
|
||||||
|
|
||||||
r = list(get_all_members(get_cluster_initialized_with_leader(), None, role=role))
|
r = list(get_all_members({}, get_cluster_initialized_with_leader(), None, role=role))
|
||||||
self.assertEqual(len(r), 1)
|
self.assertEqual(len(r), 1)
|
||||||
self.assertEqual(r[0].name, 'leader')
|
self.assertEqual(r[0].name, 'leader')
|
||||||
|
|
||||||
r = list(get_all_members(get_cluster_initialized_with_leader(), None, role='replica'))
|
r = list(get_all_members({}, get_cluster_initialized_with_leader(), None, role='replica'))
|
||||||
self.assertEqual(len(r), 1)
|
self.assertEqual(len(r), 1)
|
||||||
self.assertEqual(r[0].name, 'other')
|
self.assertEqual(r[0].name, 'other')
|
||||||
|
|
||||||
self.assertEqual(len(list(get_all_members(get_cluster_initialized_without_leader(),
|
self.assertEqual(len(list(get_all_members({}, get_cluster_initialized_without_leader(),
|
||||||
None, role='replica'))), 2)
|
None, role='replica'))), 2)
|
||||||
|
|
||||||
def test_members(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_members(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
|
||||||
result = self.runner.invoke(ctl, ['list'])
|
result = self.runner.invoke(ctl, ['list'])
|
||||||
assert '127.0.0.1' in result.output
|
assert '127.0.0.1' in result.output
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -474,115 +486,127 @@ class TestCtl(unittest.TestCase):
|
|||||||
result = self.runner.invoke(ctl, ['list', '--group', '0'])
|
result = self.runner.invoke(ctl, ['list', '--group', '0'])
|
||||||
assert 'Citus cluster: alpha (group: 0, 12345678901) -' in result.output
|
assert 'Citus cluster: alpha (group: 0, 12345678901) -' in result.output
|
||||||
|
|
||||||
config = get_default_config()
|
with patch('patroni.ctl.load_config', Mock(return_value={'scope': 'alpha'})):
|
||||||
del config['citus']
|
|
||||||
with patch('patroni.ctl.load_config', Mock(return_value=config)):
|
|
||||||
result = self.runner.invoke(ctl, ['list'])
|
result = self.runner.invoke(ctl, ['list'])
|
||||||
assert 'Cluster: alpha (12345678901) -' in result.output
|
assert 'Cluster: alpha (12345678901) -' in result.output
|
||||||
|
|
||||||
with patch('patroni.ctl.load_config', Mock(return_value={})):
|
with patch('patroni.ctl.load_config', Mock(return_value={})):
|
||||||
self.runner.invoke(ctl, ['list'])
|
self.runner.invoke(ctl, ['list'])
|
||||||
|
|
||||||
cluster = get_cluster_initialized_with_leader()
|
@patch('patroni.ctl.get_dcs')
|
||||||
cluster.members[1].data['pending_restart'] = True
|
def test_list_extended(self, mock_get_dcs):
|
||||||
cluster.members[1].data['pending_restart_reason'] = {'param': get_param_diff('', 'very l' + 'o' * 34 + 'ng')}
|
mock_get_dcs.return_value = self.e
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)):
|
cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||||
for cmd in ('list', 'topology'):
|
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
|
||||||
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'])
|
result = self.runner.invoke(ctl, ['list', 'dummy', '--extended', '--timestamp'])
|
||||||
assert '2100' in result.output
|
assert '2100' in result.output
|
||||||
assert 'Scheduled restart' in result.output
|
assert 'Scheduled restart' in result.output
|
||||||
|
|
||||||
def test_topology(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_topology(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
cluster = get_cluster_initialized_with_leader()
|
cluster = get_cluster_initialized_with_leader()
|
||||||
cluster.members.append(Member(0, 'cascade', 28,
|
cascade_member = Member(0, 'cascade', 28, {'conn_url': 'postgres://replicator:[email protected]:5437/postgres',
|
||||||
{'conn_url': 'postgres://replicator:[email protected]:5437/postgres',
|
'api_url': 'http://127.0.0.1:8012/patroni',
|
||||||
'api_url': 'http://127.0.0.1:8012/patroni', 'state': 'running',
|
'state': 'running',
|
||||||
'tags': {'replicatefrom': 'other'}}))
|
'tags': {'replicatefrom': 'other'},
|
||||||
cluster.members.append(Member(0, 'wrong_cascade', 28,
|
})
|
||||||
|
cascade_member_wrong_tags = Member(0, 'wrong_cascade', 28,
|
||||||
{'conn_url': 'postgres://replicator:[email protected]:5438/postgres',
|
{'conn_url': 'postgres://replicator:[email protected]:5438/postgres',
|
||||||
'api_url': 'http://127.0.0.1:8013/patroni', 'state': 'running',
|
'api_url': 'http://127.0.0.1:8013/patroni',
|
||||||
'tags': {'replicatefrom': 'nonexistinghost'}}))
|
'state': 'running',
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)):
|
'tags': {'replicatefrom': 'nonexistinghost'},
|
||||||
|
})
|
||||||
|
cluster.members.append(cascade_member)
|
||||||
|
cluster.members.append(cascade_member_wrong_tags)
|
||||||
|
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
|
||||||
result = self.runner.invoke(ctl, ['topology', 'dummy'])
|
result = self.runner.invoke(ctl, ['topology', 'dummy'])
|
||||||
assert '+\n| 0 | leader | 127.0.0.1:5435 | Leader |' in result.output
|
assert '+\n| 0 | leader | 127.0.0.1:5435 | Leader |' in result.output
|
||||||
assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output
|
assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output
|
||||||
assert '|\n| 0 | + cascade | 127.0.0.1:5437 | Replica |' in result.output
|
assert '|\n| 0 | + cascade | 127.0.0.1:5437 | Replica |' in result.output
|
||||||
assert '|\n| 0 | + wrong_cascade | 127.0.0.1:5438 | Replica |' in result.output
|
assert '|\n| 0 | + wrong_cascade | 127.0.0.1:5438 | Replica |' in result.output
|
||||||
|
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())):
|
cluster = get_cluster_initialized_without_leader()
|
||||||
|
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
|
||||||
result = self.runner.invoke(ctl, ['topology', 'dummy'])
|
result = self.runner.invoke(ctl, ['topology', 'dummy'])
|
||||||
assert '+\n| 0 | + leader | 127.0.0.1:5435 | Replica |' in result.output
|
assert '+\n| 0 | + leader | 127.0.0.1:5435 | Replica |' in result.output
|
||||||
assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output
|
assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output
|
||||||
|
|
||||||
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
@patch('patroni.ctl.get_dcs')
|
||||||
def test_flush_restart(self):
|
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
|
||||||
|
def test_flush_restart(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
|
||||||
for role in self.TEST_ROLES:
|
for role in self.TEST_ROLES:
|
||||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '-r', role], input='y')
|
result = self.runner.invoke(ctl, ['-k', 'flush', 'dummy', 'restart', '-r', role], input='y')
|
||||||
assert 'No scheduled restart' in result.output
|
assert 'No scheduled restart' in result.output
|
||||||
|
|
||||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
|
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
|
||||||
assert 'Success: flush scheduled restart' in result.output
|
assert 'Success: flush scheduled restart' in result.output
|
||||||
with patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse(404))):
|
with patch.object(PoolManager, 'request', return_value=MockResponse(404)):
|
||||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
|
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
|
||||||
assert 'Failed: flush scheduled restart' in result.output
|
assert 'Failed: flush scheduled restart' in result.output
|
||||||
|
|
||||||
def test_flush_switchover(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())):
|
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
|
||||||
|
def test_flush_switchover(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
|
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
|
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
|
||||||
assert 'No pending scheduled switchover' in result.output
|
assert 'No pending scheduled switchover' in result.output
|
||||||
|
|
||||||
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
|
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster',
|
mock_get_dcs.return_value.get_cluster = Mock(
|
||||||
Mock(return_value=get_cluster_initialized_with_leader(Failover(1, 'a', 'b', scheduled_at)))):
|
return_value=get_cluster_initialized_with_leader(Failover(1, 'a', 'b', scheduled_at)))
|
||||||
result = self.runner.invoke(ctl, ['-k', 'flush', 'dummy', 'switchover'])
|
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
|
||||||
assert result.output.startswith('Success: ')
|
assert result.output.startswith('Success: ')
|
||||||
|
|
||||||
with patch('patroni.ctl.request_patroni', side_effect=[MockResponse(409), Exception]), \
|
mock_get_dcs.return_value.manual_failover = Mock()
|
||||||
patch('patroni.dcs.AbstractDCS.manual_failover', Mock()):
|
with patch.object(PoolManager, 'request', side_effect=[MockResponse(409), Exception]):
|
||||||
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
|
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
|
||||||
assert 'Could not find any accessible member of cluster' in result.output
|
assert 'Could not find any accessible member of cluster' in result.output
|
||||||
|
|
||||||
|
@patch.object(PoolManager, 'request')
|
||||||
|
@patch('patroni.ctl.get_dcs')
|
||||||
@patch('patroni.ctl.polling_loop', Mock(return_value=[1]))
|
@patch('patroni.ctl.polling_loop', Mock(return_value=[1]))
|
||||||
def test_pause_cluster(self):
|
def test_pause_cluster(self, mock_get_dcs, mock_post):
|
||||||
with patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse(500))):
|
mock_get_dcs.return_value = self.e
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
|
||||||
|
mock_post.return_value.status = 500
|
||||||
result = self.runner.invoke(ctl, ['pause', 'dummy'])
|
result = self.runner.invoke(ctl, ['pause', 'dummy'])
|
||||||
assert 'Failed' in result.output
|
assert 'Failed' in result.output
|
||||||
|
|
||||||
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
|
mock_post.return_value.status = 200
|
||||||
|
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
|
||||||
result = self.runner.invoke(ctl, ['pause', 'dummy'])
|
result = self.runner.invoke(ctl, ['pause', 'dummy'])
|
||||||
assert 'Cluster is already paused' in result.output
|
assert 'Cluster is already paused' in result.output
|
||||||
|
|
||||||
result = self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
|
result = self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
|
||||||
assert "'pause' request sent" in result.output
|
assert "'pause' request sent" in result.output
|
||||||
|
mock_get_dcs.return_value.get_cluster = Mock(side_effect=[get_cluster_initialized_with_leader(),
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster',
|
get_cluster(None, None, [], None, None)])
|
||||||
Mock(side_effect=[get_cluster_initialized_with_leader(), get_cluster(None, None, [], None, None)])):
|
|
||||||
self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
|
self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster',
|
member = Member(1, 'other', 28, {})
|
||||||
Mock(side_effect=[get_cluster_initialized_with_leader(),
|
mock_get_dcs.return_value.get_cluster = Mock(side_effect=[get_cluster_initialized_with_leader(),
|
||||||
get_cluster(None, None, [Member(1, 'other', 28, {})], None, None)])):
|
get_cluster(None, None, [member], None, None)])
|
||||||
self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
|
self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
|
||||||
|
|
||||||
@patch('patroni.ctl.request_patroni')
|
@patch.object(PoolManager, 'request')
|
||||||
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
|
@patch('patroni.ctl.get_dcs')
|
||||||
def test_resume_cluster(self, mock_post):
|
def test_resume_cluster(self, mock_get_dcs, mock_post):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
|
||||||
mock_post.return_value.status = 200
|
mock_post.return_value.status = 200
|
||||||
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=False)):
|
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=False)):
|
||||||
result = self.runner.invoke(ctl, ['resume', 'dummy'])
|
result = self.runner.invoke(ctl, ['resume', 'dummy'])
|
||||||
assert 'Cluster is not paused' in result.output
|
assert 'Cluster is not paused' in result.output
|
||||||
|
|
||||||
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
|
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
|
||||||
result = self.runner.invoke(ctl, ['resume', 'dummy'])
|
result = self.runner.invoke(ctl, ['resume', 'dummy'])
|
||||||
assert 'Success' in result.output
|
assert 'Success' in result.output
|
||||||
|
|
||||||
@@ -684,38 +708,49 @@ class TestCtl(unittest.TestCase):
|
|||||||
with patch('shutil.which', Mock(return_value=e)):
|
with patch('shutil.which', Mock(return_value=e)):
|
||||||
self.assertRaises(PatroniCtlException, invoke_editor, 'foo: bar\n', 'test')
|
self.assertRaises(PatroniCtlException, invoke_editor, 'foo: bar\n', 'test')
|
||||||
|
|
||||||
def test_show_config(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_show_config(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
self.runner.invoke(ctl, ['show-config', 'dummy'])
|
self.runner.invoke(ctl, ['show-config', 'dummy'])
|
||||||
|
|
||||||
|
@patch('patroni.ctl.get_dcs')
|
||||||
@patch('subprocess.call', Mock(return_value=0))
|
@patch('subprocess.call', Mock(return_value=0))
|
||||||
def test_edit_config(self):
|
def test_edit_config(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
mock_get_dcs.return_value.set_config_value = Mock(return_value=False)
|
||||||
os.environ['EDITOR'] = 'true'
|
os.environ['EDITOR'] = 'true'
|
||||||
self.runner.invoke(ctl, ['edit-config', 'dummy'])
|
self.runner.invoke(ctl, ['edit-config', 'dummy'])
|
||||||
self.runner.invoke(ctl, ['edit-config', 'dummy', '-s', 'foo=bar'])
|
self.runner.invoke(ctl, ['edit-config', 'dummy', '-s', 'foo=bar'])
|
||||||
self.runner.invoke(ctl, ['edit-config', 'dummy', '--replace', 'postgres0.yml'])
|
self.runner.invoke(ctl, ['edit-config', 'dummy', '--replace', 'postgres0.yml'])
|
||||||
self.runner.invoke(ctl, ['edit-config', 'dummy', '--apply', '-'], input='foo: bar')
|
self.runner.invoke(ctl, ['edit-config', 'dummy', '--apply', '-'], input='foo: bar')
|
||||||
self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar')
|
self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar')
|
||||||
with patch('patroni.dcs.etcd.Etcd.set_config_value', Mock(return_value=True)):
|
mock_get_dcs.return_value.set_config_value.return_value = True
|
||||||
self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar')
|
self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar')
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=Cluster.empty())):
|
mock_get_dcs.return_value.get_cluster = Mock(return_value=Cluster.empty())
|
||||||
result = self.runner.invoke(ctl, ['edit-config', 'dummy'])
|
result = self.runner.invoke(ctl, ['edit-config', 'dummy'])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert 'The config key does not exist in the cluster dummy' in result.output
|
assert 'The config key does not exist in the cluster dummy' in result.output
|
||||||
|
|
||||||
@patch('patroni.ctl.request_patroni')
|
@patch('patroni.ctl.get_dcs')
|
||||||
def test_version(self, mock_request):
|
def test_version(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value = self.e
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
|
with patch.object(PoolManager, 'request') as mocked:
|
||||||
result = self.runner.invoke(ctl, ['version'])
|
result = self.runner.invoke(ctl, ['version'])
|
||||||
assert 'patronictl version' in result.output
|
assert 'patronictl version' in result.output
|
||||||
mock_request.return_value.data = b'{"patroni":{"version":"1.2.3"},"server_version": 100001}'
|
mocked.return_value.data = b'{"patroni":{"version":"1.2.3"},"server_version": 100001}'
|
||||||
result = self.runner.invoke(ctl, ['version', 'dummy'])
|
result = self.runner.invoke(ctl, ['version', 'dummy'])
|
||||||
assert '1.2.3' in result.output
|
assert '1.2.3' in result.output
|
||||||
mock_request.side_effect = Exception
|
with patch.object(PoolManager, 'request', Mock(side_effect=Exception)):
|
||||||
result = self.runner.invoke(ctl, ['version', 'dummy'])
|
result = self.runner.invoke(ctl, ['version', 'dummy'])
|
||||||
assert 'failed to get version' in result.output
|
assert 'failed to get version' in result.output
|
||||||
|
|
||||||
def test_history(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster') as mock_get_cluster:
|
def test_history(self, mock_get_dcs):
|
||||||
mock_get_cluster.return_value.history.lines = [[1, 67176, 'no recovery target specified']]
|
mock_get_dcs.return_value.get_cluster = Mock()
|
||||||
|
mock_get_dcs.return_value.get_cluster.return_value.history.lines = [[1, 67176, 'no recovery target specified']]
|
||||||
result = self.runner.invoke(ctl, ['history'])
|
result = self.runner.invoke(ctl, ['history'])
|
||||||
assert 'Reason' in result.output
|
assert 'Reason' in result.output
|
||||||
|
|
||||||
@@ -723,14 +758,17 @@ class TestCtl(unittest.TestCase):
|
|||||||
self.assertEqual(format_pg_version(100001), '10.1')
|
self.assertEqual(format_pg_version(100001), '10.1')
|
||||||
self.assertEqual(format_pg_version(90605), '9.6.5')
|
self.assertEqual(format_pg_version(90605), '9.6.5')
|
||||||
|
|
||||||
def test_get_members(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
with patch('patroni.dcs.AbstractDCS.get_cluster',
|
def test_get_members(self, mock_get_dcs):
|
||||||
Mock(return_value=get_cluster_not_initialized_without_leader())):
|
mock_get_dcs.return_value = self.e
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_not_initialized_without_leader
|
||||||
result = self.runner.invoke(ctl, ['reinit', 'dummy'])
|
result = self.runner.invoke(ctl, ['reinit', 'dummy'])
|
||||||
assert "cluster doesn\'t have any members" in result.output
|
assert "cluster doesn\'t have any members" in result.output
|
||||||
|
|
||||||
@patch('time.sleep', Mock())
|
@patch('time.sleep', Mock())
|
||||||
def test_reinit_wait(self):
|
@patch('patroni.ctl.get_dcs')
|
||||||
|
def test_reinit_wait(self, mock_get_dcs):
|
||||||
|
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||||
with patch.object(PoolManager, 'request') as mocked:
|
with patch.object(PoolManager, 'request') as mocked:
|
||||||
mocked.side_effect = [Mock(data=s, status=200) for s in
|
mocked.side_effect = [Mock(data=s, status=200) for s in
|
||||||
[b"reinitialize", b'{"state":"creating replica"}', b'{"state":"running"}']]
|
[b"reinitialize", b'{"state":"creating replica"}', b'{"state":"running"}']]
|
||||||
|
|||||||
+5
-8
@@ -5,10 +5,8 @@ import unittest
|
|||||||
|
|
||||||
from dns.exception import DNSException
|
from dns.exception import DNSException
|
||||||
from mock import Mock, PropertyMock, patch
|
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.dcs.etcd import AbstractDCS, EtcdClient, Cluster, Etcd, EtcdError, DnsCachingResolver
|
||||||
from patroni.exceptions import DCSError
|
from patroni.exceptions import DCSError
|
||||||
from patroni.postgresql.mpp import get_mpp
|
|
||||||
from patroni.utils import Retry
|
from patroni.utils import Retry
|
||||||
from urllib3.exceptions import ReadTimeoutError
|
from urllib3.exceptions import ReadTimeoutError
|
||||||
|
|
||||||
@@ -140,9 +138,8 @@ class TestClient(unittest.TestCase):
|
|||||||
@patch.object(EtcdClient, '_get_machines_list',
|
@patch.object(EtcdClient, '_get_machines_list',
|
||||||
Mock(return_value=['http://localhost:2379', 'http://localhost:4001']))
|
Mock(return_value=['http://localhost:2379', 'http://localhost:4001']))
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.etcd = get_dcs({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 3,
|
self.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 3,
|
||||||
'etcd': {'srv': 'test'}, 'scope': 'test', 'name': 'foo'})
|
'srv': 'test', 'scope': 'test', 'name': 'foo'})
|
||||||
self.assertIsInstance(self.etcd, Etcd)
|
|
||||||
self.client = self.etcd._client
|
self.client = self.etcd._client
|
||||||
self.client.http.request = http_request
|
self.client.http.request = http_request
|
||||||
self.client.http.request_encode_body = http_request
|
self.client.http.request_encode_body = http_request
|
||||||
@@ -238,7 +235,7 @@ class TestEtcd(unittest.TestCase):
|
|||||||
Mock(return_value=['http://localhost:2379', 'http://localhost:4001']))
|
Mock(return_value=['http://localhost:2379', 'http://localhost:4001']))
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
|
self.etcd = Etcd({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
|
||||||
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'}, get_mpp({}))
|
'host': 'localhost:2379', 'scope': 'test', 'name': 'foo'})
|
||||||
|
|
||||||
def test_base_path(self):
|
def test_base_path(self):
|
||||||
self.assertEqual(self.etcd._base_path, '/patroni/test')
|
self.assertEqual(self.etcd._base_path, '/patroni/test')
|
||||||
@@ -273,7 +270,7 @@ class TestEtcd(unittest.TestCase):
|
|||||||
self.assertRaises(EtcdError, self.etcd.get_cluster)
|
self.assertRaises(EtcdError, self.etcd.get_cluster)
|
||||||
|
|
||||||
def test__get_citus_cluster(self):
|
def test__get_citus_cluster(self):
|
||||||
self.etcd._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
self.etcd._citus_group = '0'
|
||||||
cluster = self.etcd.get_cluster()
|
cluster = self.etcd.get_cluster()
|
||||||
self.assertIsInstance(cluster, Cluster)
|
self.assertIsInstance(cluster, Cluster)
|
||||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||||
@@ -344,7 +341,7 @@ class TestEtcd(unittest.TestCase):
|
|||||||
self.assertTrue(self.etcd.watch(None, 1))
|
self.assertTrue(self.etcd.watch(None, 1))
|
||||||
|
|
||||||
def test_sync_state(self):
|
def test_sync_state(self):
|
||||||
self.assertIsNone(self.etcd.write_sync_state('leader', None, 0))
|
self.assertIsNone(self.etcd.write_sync_state('leader', None))
|
||||||
self.assertFalse(self.etcd.delete_sync_state())
|
self.assertFalse(self.etcd.delete_sync_state())
|
||||||
|
|
||||||
def test_set_history_value(self):
|
def test_set_history_value(self):
|
||||||
|
|||||||
+9
-13
@@ -4,12 +4,10 @@ import unittest
|
|||||||
import urllib3
|
import urllib3
|
||||||
|
|
||||||
from mock import Mock, PropertyMock, patch
|
from mock import Mock, PropertyMock, patch
|
||||||
from patroni.dcs import get_dcs
|
|
||||||
from patroni.dcs.etcd import DnsCachingResolver
|
from patroni.dcs.etcd import DnsCachingResolver
|
||||||
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Client, \
|
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Client, \
|
||||||
Etcd3Error, Etcd3ClientError, RetryFailedError, InvalidAuthToken, Unavailable, \
|
Etcd3Error, Etcd3ClientError, ReAuthenticateMode, RetryFailedError, InvalidAuthToken, Unavailable, \
|
||||||
Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, AuthOldRevision, base64_encode
|
Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, AuthOldRevision, base64_encode
|
||||||
from patroni.postgresql.mpp import get_mpp
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
from . import SleepException, MockResponse
|
from . import SleepException, MockResponse
|
||||||
@@ -82,9 +80,9 @@ class BaseTestEtcd3(unittest.TestCase):
|
|||||||
@patch.object(Thread, 'start', Mock())
|
@patch.object(Thread, 'start', Mock())
|
||||||
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
|
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.etcd3 = get_dcs({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10, 'name': 'foo', 'scope': 'test',
|
self.etcd3 = Etcd3({'namespace': '/patroni/', 'ttl': 30, 'retry_timeout': 10,
|
||||||
'etcd3': {'host': 'localhost:2378', 'username': 'etcduser', 'password': 'etcdpassword'}})
|
'host': 'localhost:2378', 'scope': 'test', 'name': 'foo',
|
||||||
self.assertIsInstance(self.etcd3, Etcd3)
|
'username': 'etcduser', 'password': 'etcdpassword'})
|
||||||
self.client = self.etcd3._client
|
self.client = self.etcd3._client
|
||||||
self.kv_cache = self.client._kv_cache
|
self.kv_cache = self.client._kv_cache
|
||||||
|
|
||||||
@@ -166,14 +164,12 @@ class TestPatroniEtcd3Client(BaseTestEtcd3):
|
|||||||
retry = self.etcd3._retry.copy()
|
retry = self.etcd3._retry.copy()
|
||||||
with patch('time.time', Mock(side_effect=[0, 10, 20, 30, 40])):
|
with patch('time.time', Mock(side_effect=[0, 10, 20, 30, 40])):
|
||||||
self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry)
|
self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry)
|
||||||
with patch('time.time', Mock(side_effect=[0, 10])):
|
|
||||||
self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo')
|
|
||||||
self.client.username = None
|
self.client.username = None
|
||||||
self.client._reauthenticate = False
|
self.client._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||||
retry = self.etcd3._retry.copy()
|
retry = self.etcd3._retry.copy()
|
||||||
self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry)
|
self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry)
|
||||||
mock_urlopen.return_value.content = '{"code":3,"error":"etcdserver: revision of auth store is old"}'
|
mock_urlopen.return_value.content = '{"code":3,"error":"etcdserver: revision of auth store is old"}'
|
||||||
self.client._reauthenticate = False
|
self.client._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||||
self.assertRaises(AuthOldRevision, retry, self.client.deleteprefix, 'foo', retry=retry)
|
self.assertRaises(AuthOldRevision, retry, self.client.deleteprefix, 'foo', retry=retry)
|
||||||
|
|
||||||
def test__handle_server_response(self):
|
def test__handle_server_response(self):
|
||||||
@@ -240,7 +236,7 @@ class TestEtcd3(BaseTestEtcd3):
|
|||||||
self.assertRaises(Etcd3Error, self.etcd3.get_cluster)
|
self.assertRaises(Etcd3Error, self.etcd3.get_cluster)
|
||||||
|
|
||||||
def test__get_citus_cluster(self):
|
def test__get_citus_cluster(self):
|
||||||
self.etcd3._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
self.etcd3._citus_group = '0'
|
||||||
cluster = self.etcd3.get_cluster()
|
cluster = self.etcd3.get_cluster()
|
||||||
self.assertIsInstance(cluster, Cluster)
|
self.assertIsInstance(cluster, Cluster)
|
||||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||||
@@ -273,8 +269,8 @@ class TestEtcd3(BaseTestEtcd3):
|
|||||||
|
|
||||||
def test_attempt_to_acquire_leader(self):
|
def test_attempt_to_acquire_leader(self):
|
||||||
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
||||||
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 100, 200])):
|
with patch('time.time', Mock(side_effect=[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 100, 200])):
|
||||||
self.assertFalse(self.etcd3.attempt_to_acquire_leader())
|
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
|
||||||
with patch('time.time', Mock(side_effect=[0, 100, 200, 300, 400])):
|
with patch('time.time', Mock(side_effect=[0, 100, 200, 300, 400])):
|
||||||
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
|
self.assertRaises(Etcd3Error, self.etcd3.attempt_to_acquire_leader)
|
||||||
with patch.object(PatroniEtcd3Client, 'put', Mock(return_value=False)):
|
with patch.object(PatroniEtcd3Client, 'put', Mock(return_value=False)):
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import unittest
|
|||||||
import urllib3
|
import urllib3
|
||||||
|
|
||||||
from mock import Mock, patch
|
from mock import Mock, patch
|
||||||
from patroni.dcs import get_dcs
|
|
||||||
from patroni.dcs.exhibitor import ExhibitorEnsembleProvider, Exhibitor
|
from patroni.dcs.exhibitor import ExhibitorEnsembleProvider, Exhibitor
|
||||||
from patroni.dcs.zookeeper import ZooKeeperError
|
from patroni.dcs.zookeeper import ZooKeeperError
|
||||||
|
|
||||||
@@ -27,9 +26,8 @@ class TestExhibitor(unittest.TestCase):
|
|||||||
status=200, body=b'{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}')))
|
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)
|
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.e = get_dcs({'exhibitor': {'hosts': ['localhost', 'exhibitor'], 'port': 8181},
|
self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test',
|
||||||
'scope': 'test', 'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
|
'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
|
||||||
self.assertIsInstance(self.e, Exhibitor)
|
|
||||||
|
|
||||||
@patch.object(ExhibitorEnsembleProvider, 'poll', Mock(return_value=True))
|
@patch.object(ExhibitorEnsembleProvider, 'poll', Mock(return_value=True))
|
||||||
@patch.object(MockKazooClient, 'get_children', Mock(side_effect=Exception))
|
@patch.object(MockKazooClient, 'get_children', Mock(side_effect=Exception))
|
||||||
|
|||||||
+41
-165
@@ -4,7 +4,6 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||||
from patroni import global_config
|
|
||||||
from patroni.collections import CaseInsensitiveSet
|
from patroni.collections import CaseInsensitiveSet
|
||||||
from patroni.config import Config
|
from patroni.config import Config
|
||||||
from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, Status, SyncState, TimelineHistory
|
from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, Status, SyncState, TimelineHistory
|
||||||
@@ -18,7 +17,6 @@ from patroni.postgresql.config import ConfigHandler
|
|||||||
from patroni.postgresql.postmaster import PostmasterProcess
|
from patroni.postgresql.postmaster import PostmasterProcess
|
||||||
from patroni.postgresql.rewind import Rewind
|
from patroni.postgresql.rewind import Rewind
|
||||||
from patroni.postgresql.slots import SlotsHandler
|
from patroni.postgresql.slots import SlotsHandler
|
||||||
from patroni.postgresql.sync import _SyncState
|
|
||||||
from patroni.utils import tzutc
|
from patroni.utils import tzutc
|
||||||
from patroni.watchdog import Watchdog
|
from patroni.watchdog import Watchdog
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ def get_cluster_initialized_without_leader(leader=False, failover=None, sync=Non
|
|||||||
'tags': {'clonefrom': True},
|
'tags': {'clonefrom': True},
|
||||||
'scheduled_restart': {'schedule': "2100-01-01 10:53:07.560445+00:00",
|
'scheduled_restart': {'schedule': "2100-01-01 10:53:07.560445+00:00",
|
||||||
'postgres_version': '99.0.0'}})
|
'postgres_version': '99.0.0'}})
|
||||||
syncstate = SyncState(0 if sync else None, sync and sync[0], sync and sync[1], 0)
|
syncstate = SyncState(0 if sync else None, sync and sync[0], sync and sync[1])
|
||||||
failsafe = {m.name: m.api_url for m in (m1, m2)} if failsafe else None
|
failsafe = {m.name: m.api_url for m in (m1, m2)} if failsafe else None
|
||||||
return get_cluster(SYSID, leader, [m1, m2], failover, syncstate, cluster_config, failsafe)
|
return get_cluster(SYSID, leader, [m1, m2], failover, syncstate, cluster_config, failsafe)
|
||||||
|
|
||||||
@@ -152,7 +150,6 @@ zookeeper:
|
|||||||
self.api.connection_string = 'http://127.0.0.1:8008'
|
self.api.connection_string = 'http://127.0.0.1:8008'
|
||||||
self.clonefrom = None
|
self.clonefrom = None
|
||||||
self.nosync = False
|
self.nosync = False
|
||||||
self.nostream = False
|
|
||||||
self.scheduled_restart = {'schedule': future_restart_time,
|
self.scheduled_restart = {'schedule': future_restart_time,
|
||||||
'postmaster_start_time': str(postmaster_start_time)}
|
'postmaster_start_time': str(postmaster_start_time)}
|
||||||
self.watchdog = Watchdog(self.config)
|
self.watchdog = Watchdog(self.config)
|
||||||
@@ -199,7 +196,7 @@ def run_async(self, func, args=()):
|
|||||||
@patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=False))
|
@patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=False))
|
||||||
@patch('patroni.async_executor.AsyncExecutor.run_async', run_async)
|
@patch('patroni.async_executor.AsyncExecutor.run_async', run_async)
|
||||||
@patch('patroni.postgresql.rewind.Thread', Mock())
|
@patch('patroni.postgresql.rewind.Thread', Mock())
|
||||||
@patch('patroni.postgresql.mpp.citus.CitusHandler.start', Mock())
|
@patch('patroni.postgresql.citus.CitusHandler.start', Mock())
|
||||||
@patch('subprocess.call', Mock(return_value=0))
|
@patch('subprocess.call', Mock(return_value=0))
|
||||||
@patch('time.sleep', Mock())
|
@patch('time.sleep', Mock())
|
||||||
class TestHa(PostgresInit):
|
class TestHa(PostgresInit):
|
||||||
@@ -208,7 +205,6 @@ class TestHa(PostgresInit):
|
|||||||
@patch('patroni.dcs.dcs_modules', Mock(return_value=['patroni.dcs.etcd']))
|
@patch('patroni.dcs.dcs_modules', Mock(return_value=['patroni.dcs.etcd']))
|
||||||
@patch.object(etcd.Client, 'read', etcd_read)
|
@patch.object(etcd.Client, 'read', etcd_read)
|
||||||
@patch.object(AbstractEtcdClientWithFailover, '_get_machines_list', Mock(return_value=['http://remotehost:2379']))
|
@patch.object(AbstractEtcdClientWithFailover, '_get_machines_list', Mock(return_value=['http://remotehost:2379']))
|
||||||
@patch.object(Config, '_load_cache', Mock())
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestHa, self).setUp()
|
super(TestHa, self).setUp()
|
||||||
self.p.set_state('running')
|
self.p.set_state('running')
|
||||||
@@ -221,7 +217,6 @@ class TestHa(PostgresInit):
|
|||||||
self.ha = Ha(MockPatroni(self.p, self.e))
|
self.ha = Ha(MockPatroni(self.p, self.e))
|
||||||
self.ha.old_cluster = self.e.get_cluster()
|
self.ha.old_cluster = self.e.get_cluster()
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader()
|
self.ha.cluster = get_cluster_initialized_without_leader()
|
||||||
global_config.update(self.ha.cluster)
|
|
||||||
self.ha.load_cluster_from_dcs = Mock()
|
self.ha.load_cluster_from_dcs = Mock()
|
||||||
|
|
||||||
def test_update_lock(self):
|
def test_update_lock(self):
|
||||||
@@ -256,8 +251,8 @@ class TestHa(PostgresInit):
|
|||||||
@patch('patroni.dcs.etcd.Etcd.initialize', return_value=True)
|
@patch('patroni.dcs.etcd.Etcd.initialize', return_value=True)
|
||||||
def test_bootstrap_as_standby_leader(self, initialize):
|
def test_bootstrap_as_standby_leader(self, initialize):
|
||||||
self.p.data_directory_empty = true
|
self.p.data_directory_empty = true
|
||||||
self.ha.cluster = get_cluster_not_initialized_without_leader(
|
self.ha.cluster = get_cluster_not_initialized_without_leader(cluster_config=ClusterConfig(0, {}, 0))
|
||||||
cluster_config=ClusterConfig(1, {"standby_cluster": {"port": 5432}}, 1))
|
self.ha.patroni.config._dynamic_configuration = {"standby_cluster": {"port": 5432}}
|
||||||
self.assertEqual(self.ha.run_cycle(), 'trying to bootstrap a new standby leader')
|
self.assertEqual(self.ha.run_cycle(), 'trying to bootstrap a new standby leader')
|
||||||
|
|
||||||
def test_bootstrap_waiting_for_standby_leader(self):
|
def test_bootstrap_waiting_for_standby_leader(self):
|
||||||
@@ -323,6 +318,7 @@ class TestHa(PostgresInit):
|
|||||||
self.ha.state_handler.cancellable._process = Mock()
|
self.ha.state_handler.cancellable._process = Mock()
|
||||||
self.ha._crash_recovery_started -= 600
|
self.ha._crash_recovery_started -= 600
|
||||||
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 10})
|
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 10})
|
||||||
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
self.assertEqual(self.ha.run_cycle(), 'terminated crash recovery because of startup timeout')
|
self.assertEqual(self.ha.run_cycle(), 'terminated crash recovery because of startup timeout')
|
||||||
|
|
||||||
@patch.object(Rewind, 'ensure_clean_shutdown', Mock())
|
@patch.object(Rewind, 'ensure_clean_shutdown', Mock())
|
||||||
@@ -474,11 +470,6 @@ class TestHa(PostgresInit):
|
|||||||
self.p.is_primary = false
|
self.p.is_primary = false
|
||||||
self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
|
self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
|
||||||
|
|
||||||
def test_get_node_to_follow_nostream(self):
|
|
||||||
self.ha.patroni.nostream = True
|
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader()
|
|
||||||
self.assertEqual(self.ha._get_node_to_follow(self.ha.cluster), None)
|
|
||||||
|
|
||||||
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
||||||
def test_follow(self):
|
def test_follow(self):
|
||||||
self.p.is_primary = false
|
self.p.is_primary = false
|
||||||
@@ -518,7 +509,7 @@ class TestHa(PostgresInit):
|
|||||||
def test_check_failsafe_topology(self):
|
def test_check_failsafe_topology(self):
|
||||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
|
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
|
||||||
global_config.update(self.ha.cluster)
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
self.ha.dcs._last_failsafe = self.ha.cluster.failsafe
|
self.ha.dcs._last_failsafe = self.ha.cluster.failsafe
|
||||||
self.assertEqual(self.ha.run_cycle(), 'demoting self because DCS is not accessible and I was a leader')
|
self.assertEqual(self.ha.run_cycle(), 'demoting self because DCS is not accessible and I was a leader')
|
||||||
self.ha.state_handler.name = self.ha.cluster.leader.name
|
self.ha.state_handler.name = self.ha.cluster.leader.name
|
||||||
@@ -538,7 +529,7 @@ class TestHa(PostgresInit):
|
|||||||
def test_no_dcs_connection_primary_failsafe(self):
|
def test_no_dcs_connection_primary_failsafe(self):
|
||||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
|
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
|
||||||
global_config.update(self.ha.cluster)
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
self.ha.dcs._last_failsafe = self.ha.cluster.failsafe
|
self.ha.dcs._last_failsafe = self.ha.cluster.failsafe
|
||||||
self.ha.state_handler.name = self.ha.cluster.leader.name
|
self.ha.state_handler.name = self.ha.cluster.leader.name
|
||||||
self.assertEqual(self.ha.run_cycle(),
|
self.assertEqual(self.ha.run_cycle(),
|
||||||
@@ -555,7 +546,7 @@ class TestHa(PostgresInit):
|
|||||||
def test_no_dcs_connection_replica_failsafe(self):
|
def test_no_dcs_connection_replica_failsafe(self):
|
||||||
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
|
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
|
||||||
global_config.update(self.ha.cluster)
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
self.ha.update_failsafe({'name': 'leader', 'api_url': 'http://127.0.0.1:8008/patroni',
|
self.ha.update_failsafe({'name': 'leader', 'api_url': 'http://127.0.0.1:8008/patroni',
|
||||||
'conn_url': 'postgres://127.0.0.1:5432/postgres', 'slots': {'foo': 1000}})
|
'conn_url': 'postgres://127.0.0.1:5432/postgres', 'slots': {'foo': 1000}})
|
||||||
self.p.is_primary = false
|
self.p.is_primary = false
|
||||||
@@ -598,8 +589,8 @@ class TestHa(PostgresInit):
|
|||||||
self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock')
|
self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock')
|
||||||
|
|
||||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||||
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
|
@patch('patroni.postgresql.citus.connect', psycopg_connect)
|
||||||
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
|
@patch('patroni.postgresql.citus.quote_ident', Mock())
|
||||||
@patch.object(Postgresql, 'connection', Mock(return_value=None))
|
@patch.object(Postgresql, 'connection', Mock(return_value=None))
|
||||||
def test_bootstrap_initialized_new_cluster(self):
|
def test_bootstrap_initialized_new_cluster(self):
|
||||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
@@ -620,8 +611,8 @@ class TestHa(PostgresInit):
|
|||||||
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
|
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
|
||||||
|
|
||||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||||
@patch('patroni.postgresql.mpp.citus.connect', psycopg_connect)
|
@patch('patroni.postgresql.citus.connect', psycopg_connect)
|
||||||
@patch('patroni.postgresql.mpp.citus.quote_ident', Mock())
|
@patch('patroni.postgresql.citus.quote_ident', Mock())
|
||||||
@patch.object(Postgresql, 'connection', Mock(return_value=None))
|
@patch.object(Postgresql, 'connection', Mock(return_value=None))
|
||||||
def test_bootstrap_release_initialize_key_on_watchdog_failure(self):
|
def test_bootstrap_release_initialize_key_on_watchdog_failure(self):
|
||||||
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
self.ha.cluster = get_cluster_not_initialized_without_leader()
|
||||||
@@ -664,7 +655,7 @@ class TestHa(PostgresInit):
|
|||||||
@patch.object(ConfigHandler, 'replace_pg_hba', Mock())
|
@patch.object(ConfigHandler, 'replace_pg_hba', Mock())
|
||||||
@patch.object(ConfigHandler, 'replace_pg_ident', Mock())
|
@patch.object(ConfigHandler, 'replace_pg_ident', Mock())
|
||||||
@patch.object(PostmasterProcess, 'start', Mock(return_value=MockPostmaster()))
|
@patch.object(PostmasterProcess, 'start', Mock(return_value=MockPostmaster()))
|
||||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||||
def test_worker_restart(self):
|
def test_worker_restart(self):
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.ha.patroni.request = Mock()
|
self.ha.patroni.request = Mock()
|
||||||
@@ -699,7 +690,7 @@ class TestHa(PostgresInit):
|
|||||||
self.ha.is_paused = true
|
self.ha.is_paused = true
|
||||||
self.assertEqual(self.ha.run_cycle(), 'PAUSE: restart in progress')
|
self.assertEqual(self.ha.run_cycle(), 'PAUSE: restart in progress')
|
||||||
|
|
||||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||||
def test_manual_failover_from_leader(self):
|
def test_manual_failover_from_leader(self):
|
||||||
self.ha.has_lock = true # I am the leader
|
self.ha.has_lock = true # I am the leader
|
||||||
|
|
||||||
@@ -738,7 +729,7 @@ class TestHa(PostgresInit):
|
|||||||
('Member %s exceeds maximum replication lag', 'b'))
|
('Member %s exceeds maximum replication lag', 'b'))
|
||||||
self.ha.cluster.members.pop()
|
self.ha.cluster.members.pop()
|
||||||
|
|
||||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||||
def test_manual_switchover_from_leader(self):
|
def test_manual_switchover_from_leader(self):
|
||||||
self.ha.has_lock = true # I am the leader
|
self.ha.has_lock = true # I am the leader
|
||||||
|
|
||||||
@@ -775,10 +766,11 @@ class TestHa(PostgresInit):
|
|||||||
with patch('patroni.ha.logger.info') as mock_info:
|
with patch('patroni.ha.logger.info') as mock_info:
|
||||||
self.ha.fetch_node_status = get_node_status(wal_position=1)
|
self.ha.fetch_node_status = get_node_status(wal_position=1)
|
||||||
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5})
|
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5})
|
||||||
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
|
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'))
|
self.assertEqual(mock_info.call_args_list[0][0], ('Member %s exceeds maximum replication lag', 'leader'))
|
||||||
|
|
||||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||||
def test_scheduled_switchover_from_leader(self):
|
def test_scheduled_switchover_from_leader(self):
|
||||||
self.ha.has_lock = true # I am the leader
|
self.ha.has_lock = true # I am the leader
|
||||||
|
|
||||||
@@ -1040,7 +1032,7 @@ class TestHa(PostgresInit):
|
|||||||
def test__is_healthiest_node(self):
|
def test__is_healthiest_node(self):
|
||||||
self.p.is_primary = false
|
self.p.is_primary = false
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(sync=('postgresql1', self.p.name))
|
self.ha.cluster = get_cluster_initialized_without_leader(sync=('postgresql1', self.p.name))
|
||||||
global_config.update(self.ha.cluster)
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
@@ -1057,7 +1049,7 @@ class TestHa(PostgresInit):
|
|||||||
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
|
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
|
||||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5})
|
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5})
|
||||||
global_config.update(self.ha.cluster)
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
with patch('patroni.postgresql.Postgresql.last_operation', return_value=1):
|
with patch('patroni.postgresql.Postgresql.last_operation', return_value=1):
|
||||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||||
with patch('patroni.postgresql.Postgresql.replica_cached_timeline', return_value=None):
|
with patch('patroni.postgresql.Postgresql.replica_cached_timeline', return_value=None):
|
||||||
@@ -1280,6 +1272,7 @@ class TestHa(PostgresInit):
|
|||||||
self.p.is_running = false
|
self.p.is_running = false
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(sync=(self.p.name, 'other'))
|
self.ha.cluster = get_cluster_initialized_with_leader(sync=(self.p.name, 'other'))
|
||||||
self.ha.cluster.config.data.update({'synchronous_mode': True, 'primary_start_timeout': 0})
|
self.ha.cluster.config.data.update({'synchronous_mode': True, 'primary_start_timeout': 0})
|
||||||
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.ha.update_lock = true
|
self.ha.update_lock = true
|
||||||
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
|
||||||
@@ -1289,13 +1282,13 @@ class TestHa(PostgresInit):
|
|||||||
def test_primary_stop_timeout(self):
|
def test_primary_stop_timeout(self):
|
||||||
self.assertEqual(self.ha.primary_stop_timeout(), None)
|
self.assertEqual(self.ha.primary_stop_timeout(), None)
|
||||||
self.ha.cluster.config.data.update({'primary_stop_timeout': 30})
|
self.ha.cluster.config.data.update({'primary_stop_timeout': 30})
|
||||||
global_config.update(self.ha.cluster)
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
|
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
|
||||||
self.assertEqual(self.ha.primary_stop_timeout(), 30)
|
self.assertEqual(self.ha.primary_stop_timeout(), 30)
|
||||||
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=False)):
|
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=False)):
|
||||||
self.assertEqual(self.ha.primary_stop_timeout(), None)
|
self.assertEqual(self.ha.primary_stop_timeout(), None)
|
||||||
self.ha.cluster.config.data['primary_stop_timeout'] = None
|
self.ha.cluster.config.data['primary_stop_timeout'] = None
|
||||||
global_config.update(self.ha.cluster)
|
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
|
||||||
self.assertEqual(self.ha.primary_stop_timeout(), None)
|
self.assertEqual(self.ha.primary_stop_timeout(), None)
|
||||||
|
|
||||||
@patch('patroni.postgresql.Postgresql.follow')
|
@patch('patroni.postgresql.Postgresql.follow')
|
||||||
@@ -1305,7 +1298,7 @@ class TestHa(PostgresInit):
|
|||||||
self.ha.demote('immediate')
|
self.ha.demote('immediate')
|
||||||
follow.assert_called_once_with(None)
|
follow.assert_called_once_with(None)
|
||||||
|
|
||||||
def test__process_multisync_replication(self):
|
def test_process_sync_replication(self):
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
mock_set_sync = self.p.sync_handler.set_synchronous_standby_names = Mock()
|
mock_set_sync = self.p.sync_handler.set_synchronous_standby_names = Mock()
|
||||||
self.p.name = 'leader'
|
self.p.name = 'leader'
|
||||||
@@ -1330,8 +1323,7 @@ class TestHa(PostgresInit):
|
|||||||
self.ha.is_synchronous_mode = true
|
self.ha.is_synchronous_mode = true
|
||||||
|
|
||||||
# Test sync standby not touched when picking the same node
|
# Test sync standby not touched when picking the same node
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1, 1,
|
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(['other']),
|
||||||
CaseInsensitiveSet(['other']),
|
|
||||||
CaseInsensitiveSet(['other'])))
|
CaseInsensitiveSet(['other'])))
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
@@ -1340,16 +1332,14 @@ class TestHa(PostgresInit):
|
|||||||
mock_set_sync.reset_mock()
|
mock_set_sync.reset_mock()
|
||||||
|
|
||||||
# Test sync standby is replaced when switching standbys
|
# Test sync standby is replaced when switching standbys
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, 0, CaseInsensitiveSet(),
|
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(['other2']), CaseInsensitiveSet()))
|
||||||
CaseInsensitiveSet(['other2'])))
|
|
||||||
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
mock_set_sync.assert_called_once_with(CaseInsensitiveSet(['other2']))
|
mock_set_sync.assert_called_once_with(CaseInsensitiveSet(['other2']))
|
||||||
|
|
||||||
# Test sync standby is replaced when new standby is joined
|
# Test sync standby is replaced when new standby is joined
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1, 1,
|
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(['other2', 'other3']),
|
||||||
CaseInsensitiveSet(['other2']),
|
CaseInsensitiveSet(['other2'])))
|
||||||
CaseInsensitiveSet(['other2', 'other3'])))
|
|
||||||
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
self.assertEqual(mock_set_sync.call_args_list[0][0], (CaseInsensitiveSet(['other2']),))
|
self.assertEqual(mock_set_sync.call_args_list[0][0], (CaseInsensitiveSet(['other2']),))
|
||||||
@@ -1366,8 +1356,7 @@ class TestHa(PostgresInit):
|
|||||||
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
||||||
self.ha.dcs.get_cluster = Mock(return_value=get_cluster_initialized_with_leader(sync=('leader', 'other')))
|
self.ha.dcs.get_cluster = Mock(return_value=get_cluster_initialized_with_leader(sync=('leader', 'other')))
|
||||||
# self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
# self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 1, 1,
|
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(['other2']),
|
||||||
CaseInsensitiveSet(['other2']),
|
|
||||||
CaseInsensitiveSet(['other2'])))
|
CaseInsensitiveSet(['other2'])))
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
self.assertEqual(self.ha.dcs.write_sync_state.call_count, 2)
|
self.assertEqual(self.ha.dcs.write_sync_state.call_count, 2)
|
||||||
@@ -1390,9 +1379,8 @@ class TestHa(PostgresInit):
|
|||||||
|
|
||||||
# Test sync set to '*' when synchronous_mode_strict is enabled
|
# Test sync set to '*' when synchronous_mode_strict is enabled
|
||||||
mock_set_sync.reset_mock()
|
mock_set_sync.reset_mock()
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, 0, CaseInsensitiveSet(),
|
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet()))
|
||||||
CaseInsensitiveSet()))
|
with patch('patroni.config.GlobalConfig.is_synchronous_mode_strict', PropertyMock(return_value=True)):
|
||||||
with patch.object(global_config.__class__, 'is_synchronous_mode_strict', PropertyMock(return_value=True)):
|
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
mock_set_sync.assert_called_once_with(CaseInsensitiveSet('*'))
|
mock_set_sync.assert_called_once_with(CaseInsensitiveSet('*'))
|
||||||
|
|
||||||
@@ -1409,8 +1397,8 @@ class TestHa(PostgresInit):
|
|||||||
|
|
||||||
# When we just became primary nobody is sync
|
# When we just became primary nobody is sync
|
||||||
self.assertEqual(self.ha.enforce_primary_role('msg', 'promote msg'), 'promote msg')
|
self.assertEqual(self.ha.enforce_primary_role('msg', 'promote msg'), 'promote msg')
|
||||||
mock_set_sync.assert_called_once_with(CaseInsensitiveSet(), 0)
|
mock_set_sync.assert_called_once_with(CaseInsensitiveSet())
|
||||||
mock_write_sync.assert_called_once_with('leader', None, 0, version=0)
|
mock_write_sync.assert_called_once_with('leader', None, version=0)
|
||||||
|
|
||||||
mock_set_sync.reset_mock()
|
mock_set_sync.reset_mock()
|
||||||
|
|
||||||
@@ -1448,7 +1436,7 @@ class TestHa(PostgresInit):
|
|||||||
mock_acquire.assert_called_once()
|
mock_acquire.assert_called_once()
|
||||||
mock_follow.assert_not_called()
|
mock_follow.assert_not_called()
|
||||||
mock_promote.assert_called_once()
|
mock_promote.assert_called_once()
|
||||||
mock_write_sync.assert_called_once_with('other', None, 0, version=0)
|
mock_write_sync.assert_called_once_with('other', None, version=0)
|
||||||
|
|
||||||
def test_disable_sync_when_restarting(self):
|
def test_disable_sync_when_restarting(self):
|
||||||
self.ha.is_synchronous_mode = true
|
self.ha.is_synchronous_mode = true
|
||||||
@@ -1490,8 +1478,7 @@ class TestHa(PostgresInit):
|
|||||||
self.ha.is_synchronous_mode = true
|
self.ha.is_synchronous_mode = true
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.p.name = 'leader'
|
self.p.name = 'leader'
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, 0,
|
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet()))
|
||||||
CaseInsensitiveSet(), CaseInsensitiveSet()))
|
|
||||||
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
||||||
with patch('patroni.ha.logger.info') as mock_logger:
|
with patch('patroni.ha.logger.info') as mock_logger:
|
||||||
self.ha.run_cycle()
|
self.ha.run_cycle()
|
||||||
@@ -1507,8 +1494,7 @@ class TestHa(PostgresInit):
|
|||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
self.p.name = 'leader'
|
self.p.name = 'leader'
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(sync=('leader', 'a'))
|
self.ha.cluster = get_cluster_initialized_without_leader(sync=('leader', 'a'))
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('priority', 0, 0,
|
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet('a'), CaseInsensitiveSet()))
|
||||||
CaseInsensitiveSet(), CaseInsensitiveSet('a')))
|
|
||||||
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
||||||
mock_set_sync = self.p.sync_handler.set_synchronous_standby_names = Mock()
|
mock_set_sync = self.p.sync_handler.set_synchronous_standby_names = Mock()
|
||||||
with patch('patroni.ha.logger.warning') as mock_logger:
|
with patch('patroni.ha.logger.warning') as mock_logger:
|
||||||
@@ -1528,6 +1514,7 @@ class TestHa(PostgresInit):
|
|||||||
|
|
||||||
@patch('patroni.postgresql.mtime', Mock(return_value=1588316884))
|
@patch('patroni.postgresql.mtime', Mock(return_value=1588316884))
|
||||||
@patch('builtins.open', Mock(side_effect=Exception))
|
@patch('builtins.open', Mock(side_effect=Exception))
|
||||||
|
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
||||||
def test_restore_cluster_config(self):
|
def test_restore_cluster_config(self):
|
||||||
self.ha.cluster.config.data.clear()
|
self.ha.cluster.config.data.clear()
|
||||||
self.ha.has_lock = true
|
self.ha.has_lock = true
|
||||||
@@ -1553,7 +1540,7 @@ class TestHa(PostgresInit):
|
|||||||
self.ha.is_failover_possible = true
|
self.ha.is_failover_possible = true
|
||||||
self.ha.shutdown()
|
self.ha.shutdown()
|
||||||
|
|
||||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||||
def test_shutdown_citus_worker(self):
|
def test_shutdown_citus_worker(self):
|
||||||
self.ha.is_leader = true
|
self.ha.is_leader = true
|
||||||
self.p.is_running = Mock(side_effect=[Mock(), False])
|
self.p.is_running = Mock(side_effect=[Mock(), False])
|
||||||
@@ -1665,126 +1652,15 @@ class TestHa(PostgresInit):
|
|||||||
self.assertRaises(DCSError, self.ha.acquire_lock)
|
self.assertRaises(DCSError, self.ha.acquire_lock)
|
||||||
self.assertFalse(self.ha.acquire_lock())
|
self.assertFalse(self.ha.acquire_lock())
|
||||||
|
|
||||||
@patch('patroni.postgresql.mpp.AbstractMPPHandler.is_coordinator', Mock(return_value=False))
|
@patch('patroni.postgresql.citus.CitusHandler.is_coordinator', Mock(return_value=False))
|
||||||
def test_notify_citus_coordinator(self):
|
def test_notify_citus_coordinator(self):
|
||||||
self.ha.patroni.request = Mock()
|
self.ha.patroni.request = Mock()
|
||||||
self.ha.notify_mpp_coordinator('before_demote')
|
self.ha.notify_citus_coordinator('before_demote')
|
||||||
self.ha.patroni.request.assert_called_once()
|
self.ha.patroni.request.assert_called_once()
|
||||||
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 30)
|
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 30)
|
||||||
self.ha.patroni.request = Mock(side_effect=Exception)
|
self.ha.patroni.request = Mock(side_effect=Exception)
|
||||||
with patch('patroni.ha.logger.warning') as mock_logger:
|
with patch('patroni.ha.logger.warning') as mock_logger:
|
||||||
self.ha.notify_mpp_coordinator('before_promote')
|
self.ha.notify_citus_coordinator('before_promote')
|
||||||
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 2)
|
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 2)
|
||||||
mock_logger.assert_called()
|
mock_logger.assert_called()
|
||||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Request to %s coordinator leader'))
|
self.assertTrue(mock_logger.call_args[0][0].startswith('Request to Citus coordinator'))
|
||||||
self.assertEqual(mock_logger.call_args[0][1], 'Citus')
|
|
||||||
|
|
||||||
@patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True))
|
|
||||||
@patch.object(global_config.__class__, 'is_quorum_commit_mode', PropertyMock(return_value=True))
|
|
||||||
def test_process_sync_replication_prepromote(self):
|
|
||||||
self.p._major_version = 90500
|
|
||||||
self.ha.cluster = get_cluster_initialized_without_leader(sync=('other', self.p.name + ',foo'))
|
|
||||||
self.p.is_primary = false
|
|
||||||
self.p.set_role('replica')
|
|
||||||
mock_write_sync = self.ha.dcs.write_sync_state = Mock(return_value=None)
|
|
||||||
# Postgres 9.5, write_sync_state to DCS failed
|
|
||||||
self.assertEqual(self.ha.run_cycle(),
|
|
||||||
'Postponing promotion because synchronous replication state was updated by somebody else')
|
|
||||||
self.assertEqual(self.ha.dcs.write_sync_state.call_count, 1)
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][0], (self.p.name, None, 0))
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][1], {'version': 0})
|
|
||||||
|
|
||||||
mock_set_sync = self.p.config.set_synchronous_standby_names = Mock()
|
|
||||||
mock_write_sync = self.ha.dcs.write_sync_state = Mock(return_value=True)
|
|
||||||
# Postgres 9.5, our name is written to leader of the /sync key, while voters list and ssn is empty
|
|
||||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
|
||||||
self.assertEqual(self.ha.dcs.write_sync_state.call_count, 1)
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][0], (self.p.name, None, 0))
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][1], {'version': 0})
|
|
||||||
self.assertEqual(mock_set_sync.call_count, 1)
|
|
||||||
self.assertEqual(mock_set_sync.call_args_list[0][0], (None,))
|
|
||||||
|
|
||||||
self.p._major_version = 90600
|
|
||||||
mock_set_sync.reset_mock()
|
|
||||||
mock_write_sync.reset_mock()
|
|
||||||
self.p.set_role('replica')
|
|
||||||
# Postgres 9.6, with quorum commit we avoid updating /sync key and put some nodes to ssn
|
|
||||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
|
||||||
self.assertEqual(mock_write_sync.call_count, 0)
|
|
||||||
self.assertEqual(mock_set_sync.call_count, 1)
|
|
||||||
self.assertEqual(mock_set_sync.call_args_list[0][0], ('2 (foo,other)',))
|
|
||||||
|
|
||||||
self.p._major_version = 150000
|
|
||||||
mock_set_sync.reset_mock()
|
|
||||||
self.p.set_role('replica')
|
|
||||||
self.p.name = 'nonsync'
|
|
||||||
self.ha.fetch_node_status = get_node_status()
|
|
||||||
# Postgres 15, with quorum commit. Non-sync node promoted we avoid updating /sync key and put some nodes to ssn
|
|
||||||
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader by acquiring session lock')
|
|
||||||
self.assertEqual(mock_write_sync.call_count, 0)
|
|
||||||
self.assertEqual(mock_set_sync.call_count, 1)
|
|
||||||
self.assertEqual(mock_set_sync.call_args_list[0][0], ('ANY 3 (foo,other,postgresql0)',))
|
|
||||||
|
|
||||||
@patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True))
|
|
||||||
@patch.object(global_config.__class__, 'is_quorum_commit_mode', PropertyMock(return_value=True))
|
|
||||||
def test__process_quorum_replication(self):
|
|
||||||
self.p._major_version = 150000
|
|
||||||
self.ha.has_lock = true
|
|
||||||
mock_set_sync = self.p.config.set_synchronous_standby_names = Mock()
|
|
||||||
self.p.name = 'leader'
|
|
||||||
|
|
||||||
mock_write_sync = self.ha.dcs.write_sync_state = Mock(return_value=None)
|
|
||||||
# Test /sync key is attempted to set and failed when missing or invalid
|
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('quorum', 1, 1, CaseInsensitiveSet(['other']),
|
|
||||||
CaseInsensitiveSet(['other'])))
|
|
||||||
self.ha.run_cycle()
|
|
||||||
self.assertEqual(mock_write_sync.call_count, 1)
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][0], (self.p.name, None, 0))
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][1], {'version': None})
|
|
||||||
self.assertEqual(mock_set_sync.call_count, 0)
|
|
||||||
|
|
||||||
self.ha._promote_timestamp = 1
|
|
||||||
mock_write_sync = self.ha.dcs.write_sync_state = Mock(side_effect=[SyncState(None, self.p.name, None, 0), None])
|
|
||||||
# Test /sync key is attempted to set and succeed when missing or invalid
|
|
||||||
with patch.object(SyncState, 'is_empty', Mock(side_effect=[True, False])):
|
|
||||||
self.ha.run_cycle()
|
|
||||||
self.assertEqual(mock_write_sync.call_count, 2)
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][0], (self.p.name, None, 0))
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][1], {'version': None})
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[1][0], (self.p.name, CaseInsensitiveSet(['other']), 0))
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[1][1], {'version': None})
|
|
||||||
self.assertEqual(mock_set_sync.call_count, 0)
|
|
||||||
|
|
||||||
self.p.sync_handler.current_state = Mock(side_effect=[_SyncState('quorum', 1, 0, CaseInsensitiveSet(['foo']),
|
|
||||||
CaseInsensitiveSet(['other'])),
|
|
||||||
_SyncState('quorum', 1, 1, CaseInsensitiveSet(['foo']),
|
|
||||||
CaseInsensitiveSet(['foo']))])
|
|
||||||
mock_write_sync = self.ha.dcs.write_sync_state = Mock(return_value=SyncState(1, 'leader', 'foo', 0))
|
|
||||||
self.ha.cluster = get_cluster_initialized_with_leader(sync=('leader', 'foo'))
|
|
||||||
# Test the sync node is removed from voters, added to ssn
|
|
||||||
with patch.object(Postgresql, 'synchronous_standby_names', Mock(return_value='other')), \
|
|
||||||
patch('time.sleep', Mock()):
|
|
||||||
self.ha.run_cycle()
|
|
||||||
self.assertEqual(mock_write_sync.call_count, 1)
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][0], (self.p.name, CaseInsensitiveSet(), 0))
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][1], {'version': 0})
|
|
||||||
self.assertEqual(mock_set_sync.call_count, 1)
|
|
||||||
self.assertEqual(mock_set_sync.call_args_list[0][0], ('ANY 1 (other)',))
|
|
||||||
|
|
||||||
# Test ANY 1 (*) when synchronous_mode_strict and no nodes available
|
|
||||||
self.p.sync_handler.current_state = Mock(return_value=_SyncState('quorum', 1, 0,
|
|
||||||
CaseInsensitiveSet(['other', 'foo']),
|
|
||||||
CaseInsensitiveSet()))
|
|
||||||
mock_write_sync.reset_mock()
|
|
||||||
mock_set_sync.reset_mock()
|
|
||||||
with patch.object(global_config.__class__, 'is_synchronous_mode_strict', PropertyMock(return_value=True)):
|
|
||||||
self.ha.run_cycle()
|
|
||||||
self.assertEqual(mock_write_sync.call_count, 1)
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][0], (self.p.name, CaseInsensitiveSet(), 0))
|
|
||||||
self.assertEqual(mock_write_sync.call_args_list[0][1], {'version': 0})
|
|
||||||
self.assertEqual(mock_set_sync.call_count, 1)
|
|
||||||
self.assertEqual(mock_set_sync.call_args_list[0][0], ('ANY 1 (*)',))
|
|
||||||
|
|
||||||
# Test that _process_quorum_replication doesn't take longer than loop_wait
|
|
||||||
with patch('time.time', Mock(side_effect=[30, 60, 90, 120])):
|
|
||||||
self.ha.process_sync_replication()
|
|
||||||
|
|||||||
+17
-35
@@ -8,17 +8,14 @@ import unittest
|
|||||||
import urllib3
|
import urllib3
|
||||||
|
|
||||||
from mock import Mock, PropertyMock, mock_open, patch
|
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, \
|
from patroni.dcs.kubernetes import Cluster, k8s_client, k8s_config, K8sConfig, K8sConnectionFailed, \
|
||||||
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException, \
|
K8sException, K8sObject, Kubernetes, KubernetesError, KubernetesRetriableException, \
|
||||||
Retry, RetryFailedError, SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME
|
Retry, RetryFailedError, SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME
|
||||||
from patroni.postgresql.mpp import get_mpp
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from . import MockResponse, SleepException
|
from . import MockResponse, SleepException
|
||||||
|
|
||||||
|
|
||||||
def mock_list_namespaced_config_map(*args, **kwargs):
|
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',
|
metadata = {'resource_version': '1', 'labels': {'f': 'b'}, 'name': 'test-config',
|
||||||
'annotations': {'initialize': '123', 'config': '{}'}}
|
'annotations': {'initialize': '123', 'config': '{}'}}
|
||||||
items = [k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))]
|
items = [k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))]
|
||||||
@@ -29,16 +26,16 @@ def mock_list_namespaced_config_map(*args, **kwargs):
|
|||||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||||
metadata.update({'name': 'test-sync', 'annotations': {'leader': 'p-0'}})
|
metadata.update({'name': 'test-sync', 'annotations': {'leader': 'p-0'}})
|
||||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||||
metadata.update({'name': 'test-0-leader', 'labels': {k8s_group_label: '0'},
|
metadata.update({'name': 'test-0-leader', 'labels': {Kubernetes._CITUS_LABEL: '0'},
|
||||||
'annotations': {'optime': '1234x', 'leader': 'p-0', 'ttl': '30s', 'slots': '{', 'failsafe': '{'}})
|
'annotations': {'optime': '1234x', 'leader': 'p-0', 'ttl': '30s', 'slots': '{', 'failsafe': '{'}})
|
||||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||||
metadata.update({'name': 'test-0-config', 'labels': {k8s_group_label: '0'},
|
metadata.update({'name': 'test-0-config', 'labels': {Kubernetes._CITUS_LABEL: '0'},
|
||||||
'annotations': {'initialize': '123', 'config': '{}'}})
|
'annotations': {'initialize': '123', 'config': '{}'}})
|
||||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||||
metadata.update({'name': 'test-1-leader', 'labels': {k8s_group_label: '1'},
|
metadata.update({'name': 'test-1-leader', 'labels': {Kubernetes._CITUS_LABEL: '1'},
|
||||||
'annotations': {'leader': 'p-3', 'ttl': '30s'}})
|
'annotations': {'leader': 'p-3', 'ttl': '30s'}})
|
||||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||||
metadata.update({'name': 'test-2-config', 'labels': {k8s_group_label: '2'}, 'annotations': {}})
|
metadata.update({'name': 'test-2-config', 'labels': {Kubernetes._CITUS_LABEL: '2'}, 'annotations': {}})
|
||||||
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
|
||||||
|
|
||||||
metadata = k8s_client.V1ObjectMeta(resource_version='1')
|
metadata = k8s_client.V1ObjectMeta(resource_version='1')
|
||||||
@@ -63,8 +60,7 @@ def mock_list_namespaced_endpoints(*args, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def mock_list_namespaced_pod(*args, **kwargs):
|
def mock_list_namespaced_pod(*args, **kwargs):
|
||||||
k8s_group_label = get_mpp({'citus': {'group': 0, 'database': 'postgres'}}).k8s_group_label
|
metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b', Kubernetes._CITUS_LABEL: '1'},
|
||||||
metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b', k8s_group_label: '1'},
|
|
||||||
name='p-0', annotations={'status': '{}'},
|
name='p-0', annotations={'status': '{}'},
|
||||||
uid='964dfeae-e79b-4476-8a5a-1920b5c2a69d')
|
uid='964dfeae-e79b-4476-8a5a-1920b5c2a69d')
|
||||||
status = k8s_client.V1PodStatus(pod_ip='10.0.0.1')
|
status = k8s_client.V1PodStatus(pod_ip='10.0.0.1')
|
||||||
@@ -229,12 +225,11 @@ 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_pod', mock_list_namespaced_pod, create=True)
|
||||||
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
|
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_config_map', mock_list_namespaced_config_map, create=True)
|
||||||
def setUp(self, config=None):
|
def setUp(self, config=None):
|
||||||
config = {'ttl': 30, 'scope': 'test', 'name': 'p-0', 'loop_wait': 10, 'retry_timeout': 10,
|
config = config or {}
|
||||||
'kubernetes': {'labels': {'f': 'b'}, 'bypass_api_service': True, **(config or {})},
|
config.update(ttl=30, scope='test', name='p-0', loop_wait=10, group=0,
|
||||||
'citus': {'group': 0, 'database': 'postgres'}}
|
retry_timeout=10, labels={'f': 'b'}, bypass_api_service=True)
|
||||||
self.k = get_dcs(config)
|
self.k = Kubernetes(config)
|
||||||
self.assertIsInstance(self.k, Kubernetes)
|
self.k._citus_group = None
|
||||||
self.k._mpp = get_mpp({})
|
|
||||||
self.assertRaises(AttributeError, self.k._pods._build_cache)
|
self.assertRaises(AttributeError, self.k._pods._build_cache)
|
||||||
self.k._pods._is_ready = True
|
self.k._pods._is_ready = True
|
||||||
self.assertRaises(TypeError, self.k._kinds._build_cache)
|
self.assertRaises(TypeError, self.k._kinds._build_cache)
|
||||||
@@ -259,31 +254,18 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
|
|||||||
self.assertRaises(KubernetesError, self.k.get_cluster)
|
self.assertRaises(KubernetesError, self.k.get_cluster)
|
||||||
|
|
||||||
def test__get_citus_cluster(self):
|
def test__get_citus_cluster(self):
|
||||||
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
self.k._citus_group = '0'
|
||||||
cluster = self.k.get_cluster()
|
cluster = self.k.get_cluster()
|
||||||
self.assertIsInstance(cluster, Cluster)
|
self.assertIsInstance(cluster, Cluster)
|
||||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||||
|
|
||||||
@patch('patroni.dcs.kubernetes.logger.error')
|
|
||||||
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.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')
|
@patch('patroni.dcs.kubernetes.logger.error')
|
||||||
def test_get_citus_coordinator(self, mock_logger):
|
def test_get_citus_coordinator(self, mock_logger):
|
||||||
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
self.assertIsInstance(self.k.get_citus_coordinator(), Cluster)
|
||||||
self.assertIsInstance(self.k.get_mpp_coordinator(), Cluster)
|
with patch.object(Kubernetes, '_cluster_loader', Mock(side_effect=Exception)):
|
||||||
with patch.object(Kubernetes, '_postgresql_cluster_loader', Mock(side_effect=Exception)):
|
self.assertIsNone(self.k.get_citus_coordinator())
|
||||||
self.assertIsNone(self.k.get_mpp_coordinator())
|
|
||||||
mock_logger.assert_called()
|
mock_logger.assert_called()
|
||||||
self.assertEqual(mock_logger.call_args[0][0], 'Failed to load %s coordinator cluster from Kubernetes: %r')
|
self.assertTrue(mock_logger.call_args[0][0].startswith('Failed to load Citus coordinator'))
|
||||||
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):
|
def test_attempt_to_acquire_leader(self):
|
||||||
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', create=True) as mock_patch:
|
with patch.object(k8s_client.CoreV1Api, 'patch_namespaced_config_map', create=True) as mock_patch:
|
||||||
@@ -437,7 +419,7 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
|||||||
|
|
||||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', mock_namespaced_kind, create=True)
|
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', mock_namespaced_kind, create=True)
|
||||||
def test_write_sync_state(self):
|
def test_write_sync_state(self):
|
||||||
self.assertIsNotNone(self.k.write_sync_state('a', ['b'], 0, 1))
|
self.assertIsNotNone(self.k.write_sync_state('a', ['b'], 1))
|
||||||
|
|
||||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', mock_namespaced_kind, create=True)
|
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_pod', mock_namespaced_kind, create=True)
|
||||||
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints', mock_namespaced_kind, create=True)
|
@patch.object(k8s_client.CoreV1Api, 'create_namespaced_endpoints', mock_namespaced_kind, create=True)
|
||||||
@@ -484,7 +466,7 @@ class TestCacheBuilder(BaseTestKubernetes):
|
|||||||
@patch('patroni.dcs.kubernetes.ObjectCache._watch', mock_watch)
|
@patch('patroni.dcs.kubernetes.ObjectCache._watch', mock_watch)
|
||||||
@patch.object(urllib3.HTTPResponse, 'read_chunked')
|
@patch.object(urllib3.HTTPResponse, 'read_chunked')
|
||||||
def test__build_cache(self, mock_read_chunked):
|
def test__build_cache(self, mock_read_chunked):
|
||||||
self.k._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
self.k._citus_group = '0'
|
||||||
mock_read_chunked.return_value = [json.dumps(
|
mock_read_chunked.return_value = [json.dumps(
|
||||||
{'type': 'MODIFIED', 'object': {'metadata': {
|
{'type': 'MODIFIED', 'object': {'metadata': {
|
||||||
'name': self.k.config_path, 'resourceVersion': '2', 'annotations': {self.k._CONFIG: 'foo'}}}}
|
'name': self.k.config_path, 'resourceVersion': '2', 'annotations': {self.k._CONFIG: 'foo'}}}}
|
||||||
|
|||||||
@@ -3,23 +3,12 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
import yaml
|
import yaml
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
from mock import Mock, patch
|
from mock import Mock, patch
|
||||||
from patroni.config import Config
|
from patroni.config import Config
|
||||||
from patroni.log import PatroniLogger
|
from patroni.log import PatroniLogger
|
||||||
from queue import Queue, Full
|
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__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,201 +72,3 @@ class TestPatroniLogger(unittest.TestCase):
|
|||||||
_LOG.info('blabla')
|
_LOG.info('blabla')
|
||||||
logger.shutdown()
|
logger.shutdown()
|
||||||
self.assertEqual(logger.records_lost, 0)
|
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'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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({}))
|
|
||||||
@@ -154,7 +154,6 @@ class TestPatroni(unittest.TestCase):
|
|||||||
self.p.api.start = Mock()
|
self.p.api.start = Mock()
|
||||||
self.p.logger.start = Mock()
|
self.p.logger.start = Mock()
|
||||||
self.p.config._dynamic_configuration = {}
|
self.p.config._dynamic_configuration = {}
|
||||||
self.assertRaises(SleepException, self.p.run)
|
|
||||||
with patch('patroni.dcs.Cluster.is_unlocked', Mock(return_value=True)):
|
with patch('patroni.dcs.Cluster.is_unlocked', Mock(return_value=True)):
|
||||||
self.assertRaises(SleepException, self.p.run)
|
self.assertRaises(SleepException, self.p.run)
|
||||||
with patch('patroni.config.Config.reload_local_configuration', Mock(return_value=False)):
|
with patch('patroni.config.Config.reload_local_configuration', Mock(return_value=False)):
|
||||||
@@ -249,16 +248,6 @@ class TestPatroni(unittest.TestCase):
|
|||||||
self.p.tags['nosync'] = None
|
self.p.tags['nosync'] = None
|
||||||
self.assertFalse(self.p.nosync)
|
self.assertFalse(self.p.nosync)
|
||||||
|
|
||||||
def test_nostream(self):
|
|
||||||
self.p.tags['nostream'] = 'True'
|
|
||||||
self.assertTrue(self.p.nostream)
|
|
||||||
self.p.tags['nostream'] = 'None'
|
|
||||||
self.assertFalse(self.p.nostream)
|
|
||||||
self.p.tags['nostream'] = 'foo'
|
|
||||||
self.assertFalse(self.p.nostream)
|
|
||||||
self.p.tags['nostream'] = ''
|
|
||||||
self.assertFalse(self.p.nostream)
|
|
||||||
|
|
||||||
@patch.object(Thread, 'join', Mock())
|
@patch.object(Thread, 'join', Mock())
|
||||||
def test_shutdown(self):
|
def test_shutdown(self):
|
||||||
self.p.api.shutdown = Mock(side_effect=Exception)
|
self.p.api.shutdown = Mock(side_effect=Exception)
|
||||||
|
|||||||
+39
-93
@@ -7,19 +7,18 @@ import time
|
|||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import patroni.psycopg as psycopg
|
import patroni.psycopg as psycopg
|
||||||
|
|
||||||
from patroni import global_config
|
|
||||||
from patroni.async_executor import CriticalTask
|
from patroni.async_executor import CriticalTask
|
||||||
from patroni.collections import CaseInsensitiveDict, CaseInsensitiveSet
|
from patroni.collections import CaseInsensitiveSet
|
||||||
|
from patroni.config import GlobalConfig
|
||||||
from patroni.dcs import RemoteMember
|
from patroni.dcs import RemoteMember
|
||||||
from patroni.exceptions import PostgresConnectionException, PatroniException
|
from patroni.exceptions import PostgresConnectionException, PatroniException
|
||||||
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
|
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
|
||||||
from patroni.postgresql.bootstrap import Bootstrap
|
from patroni.postgresql.bootstrap import Bootstrap
|
||||||
from patroni.postgresql.callback_executor import CallbackAction
|
from patroni.postgresql.callback_executor import CallbackAction
|
||||||
from patroni.postgresql.config import get_param_diff, _false_validator
|
from patroni.postgresql.config import _false_validator
|
||||||
from patroni.postgresql.postmaster import PostmasterProcess
|
from patroni.postgresql.postmaster import PostmasterProcess
|
||||||
from patroni.postgresql.validator import (ValidatorFactoryNoType, ValidatorFactoryInvalidType,
|
from patroni.postgresql.validator import (ValidatorFactoryNoType, ValidatorFactoryInvalidType,
|
||||||
ValidatorFactoryInvalidSpec, ValidatorFactory, InvalidGucValidatorsFile,
|
ValidatorFactoryInvalidSpec, ValidatorFactory, InvalidGucValidatorsFile,
|
||||||
@@ -364,7 +363,7 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
@patch.object(Postgresql, 'start', Mock())
|
@patch.object(Postgresql, 'start', Mock())
|
||||||
def test_follow(self):
|
def test_follow(self):
|
||||||
self.p.call_nowait(CallbackAction.ON_START)
|
self.p.call_nowait(CallbackAction.ON_START)
|
||||||
m = RemoteMember('1', {'restore_command': '2', 'primary_slot_name': 'foo', 'conn_kwargs': {'host': 'foo,bar'}})
|
m = RemoteMember('1', {'restore_command': '2', 'primary_slot_name': 'foo', 'conn_kwargs': {'host': 'bar'}})
|
||||||
self.p.follow(m)
|
self.p.follow(m)
|
||||||
with patch.object(Postgresql, 'ensure_major_version_is_known', Mock(return_value=False)):
|
with patch.object(Postgresql, 'ensure_major_version_is_known', Mock(return_value=False)):
|
||||||
self.assertIsNone(self.p.follow(m))
|
self.assertIsNone(self.p.follow(m))
|
||||||
@@ -572,7 +571,7 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
self.p.reload_config(config)
|
self.p.reload_config(config)
|
||||||
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
|
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
|
||||||
mock_warning.assert_not_called()
|
mock_warning.assert_not_called()
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
self.assertEqual(self.p.pending_restart, False)
|
||||||
|
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
|
|
||||||
@@ -580,7 +579,7 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
config['parameters']['archive_cleanup_command'] = 'blabla'
|
config['parameters']['archive_cleanup_command'] = 'blabla'
|
||||||
self.p.reload_config(config)
|
self.p.reload_config(config)
|
||||||
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
|
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
self.assertEqual(self.p.pending_restart, False)
|
||||||
|
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
|
|
||||||
@@ -588,7 +587,7 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
self.p.config._config['parameters']['wal_buffers'] = '512'
|
self.p.config._config['parameters']['wal_buffers'] = '512'
|
||||||
self.p.reload_config(config)
|
self.p.reload_config(config)
|
||||||
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
|
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
self.assertEqual(self.p.pending_restart, False)
|
||||||
|
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
config = deepcopy(self.p.config._config)
|
config = deepcopy(self.p.config._config)
|
||||||
@@ -598,60 +597,51 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
config['pg_ident'] = ['']
|
config['pg_ident'] = ['']
|
||||||
self.p.reload_config(config)
|
self.p.reload_config(config)
|
||||||
mock_info.assert_called_once_with('Reloading PostgreSQL configuration.')
|
mock_info.assert_called_once_with('Reloading PostgreSQL configuration.')
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
self.assertEqual(self.p.pending_restart, False)
|
||||||
|
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
|
|
||||||
# Postmaster parameter change (pending_restart)
|
# Postmaster parameter change (pending_restart)
|
||||||
init_max_worker_processes = config['parameters']['max_worker_processes']
|
init_max_worker_processes = config['parameters']['max_worker_processes']
|
||||||
config['parameters']['max_worker_processes'] *= 2
|
config['parameters']['max_worker_processes'] *= 2
|
||||||
new_max_worker_processes = config['parameters']['max_worker_processes']
|
with patch('patroni.postgresql.Postgresql._query', Mock(side_effect=[GET_PG_SETTINGS_RESULT, [(1,)]])):
|
||||||
# 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.p.reload_config(config)
|
||||||
self.assertEqual(mock_info.call_args_list[0][0],
|
self.assertEqual(mock_info.call_args_list[0][0], ('Changed %s from %s to %s (restart might be required)',
|
||||||
("Changed %s from '%s' to '%s' (restart might be required)", 'max_worker_processes',
|
'max_worker_processes', str(init_max_worker_processes),
|
||||||
str(init_max_worker_processes), config['parameters']['max_worker_processes']))
|
config['parameters']['max_worker_processes']))
|
||||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||||
self.assertEqual(self.p.pending_restart_reason,
|
self.assertEqual(self.p.pending_restart, True)
|
||||||
CaseInsensitiveDict({'max_worker_processes': get_param_diff(init_max_worker_processes,
|
|
||||||
new_max_worker_processes)}))
|
|
||||||
|
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
|
|
||||||
# Reset to the initial value without restart
|
# Reset to the initial value without restart
|
||||||
config['parameters']['max_worker_processes'] = init_max_worker_processes
|
config['parameters']['max_worker_processes'] = init_max_worker_processes
|
||||||
self.p.reload_config(config)
|
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,
|
init_max_worker_processes * 2,
|
||||||
config['parameters']['max_worker_processes']))
|
str(config['parameters']['max_worker_processes'])))
|
||||||
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
self.assertEqual(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
self.assertEqual(self.p.pending_restart, False)
|
||||||
|
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
|
|
||||||
# User-defined parameter changed (removed)
|
# User-defined parameter changed (removed)
|
||||||
config['parameters'].pop('f.oo')
|
config['parameters'].pop('f.oo')
|
||||||
self.p.reload_config(config)
|
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(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
self.assertEqual(self.p.pending_restart, False)
|
||||||
|
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
|
|
||||||
# Non-postmaster parameter change
|
# Non-postmaster parameter change
|
||||||
config['parameters']['vacuum_cost_delay'] = 2.5
|
config['parameters']['autovacuum'] = 'off'
|
||||||
self.p.reload_config(config)
|
self.p.reload_config(config)
|
||||||
self.assertEqual(mock_info.call_args_list[0][0],
|
self.assertEqual(mock_info.call_args_list[0][0], ("Changed %s from %s to %s", 'autovacuum', 'on', 'off'))
|
||||||
("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(mock_info.call_args_list[1][0], ('Reloading PostgreSQL configuration.',))
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
self.assertEqual(self.p.pending_restart, False)
|
||||||
|
|
||||||
config['parameters']['vacuum_cost_delay'] = 200
|
config['parameters']['autovacuum'] = 'on'
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
|
|
||||||
# Remove invalid parameter
|
# Remove invalid parameter
|
||||||
@@ -664,35 +654,13 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
mock_warning.reset_mock()
|
mock_warning.reset_mock()
|
||||||
mock_info.reset_mock()
|
mock_info.reset_mock()
|
||||||
|
|
||||||
# Non-empty result (outside changes)
|
# Non-empty result (outside changes) and exception while querying pending_restart parameters
|
||||||
with patch.object(Postgresql, 'get_guc_value', Mock(side_effect=['73', None, ''])), \
|
with patch('patroni.postgresql.Postgresql._query',
|
||||||
patch('patroni.postgresql.Postgresql._query',
|
Mock(side_effect=[GET_PG_SETTINGS_RESULT, [(1,)], GET_PG_SETTINGS_RESULT, Exception])):
|
||||||
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.p.reload_config(config, True)
|
||||||
self.assertEqual(mock_info.call_args_list[0][0],
|
self.assertEqual(mock_info.call_args_list[0][0], ('Reloading PostgreSQL configuration.',))
|
||||||
("Changed %s from '%s' to '%s' (restart might be required)",
|
self.assertEqual(self.p.pending_restart, True)
|
||||||
'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.
|
# Invalid values, just to increase silly coverage in postgresql.validator.
|
||||||
# One day we will have proper tests there.
|
# One day we will have proper tests there.
|
||||||
config['parameters']['autovacuum'] = 'of' # Bool.transform()
|
config['parameters']['autovacuum'] = 'of' # Bool.transform()
|
||||||
@@ -807,9 +775,9 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
|
|
||||||
def test_get_server_parameters(self):
|
def test_get_server_parameters(self):
|
||||||
config = {'parameters': {'wal_level': 'hot_standby', 'max_prepared_transactions': 100}, 'listen': '0'}
|
config = {'parameters': {'wal_level': 'hot_standby', 'max_prepared_transactions': 100}, 'listen': '0'}
|
||||||
with patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True)):
|
self.p._global_config = GlobalConfig({'synchronous_mode': True})
|
||||||
self.p.config.get_server_parameters(config)
|
self.p.config.get_server_parameters(config)
|
||||||
with patch.object(global_config.__class__, 'is_synchronous_mode_strict', PropertyMock(return_value=True)):
|
self.p._global_config = GlobalConfig({'synchronous_mode': True, 'synchronous_mode_strict': True})
|
||||||
self.p.config.get_server_parameters(config)
|
self.p.config.get_server_parameters(config)
|
||||||
self.p.config.set_synchronous_standby_names('foo')
|
self.p.config.set_synchronous_standby_names('foo')
|
||||||
self.assertTrue(str(self.p.config.get_server_parameters(config)).startswith('<CaseInsensitiveDict'))
|
self.assertTrue(str(self.p.config.get_server_parameters(config)).startswith('<CaseInsensitiveDict'))
|
||||||
@@ -850,7 +818,7 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
|
patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
|
||||||
self.p.cancellable.cancel()
|
self.p.cancellable.cancel()
|
||||||
self.assertFalse(self.p.start())
|
self.assertFalse(self.p.start())
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict())
|
self.assertFalse(self.p.pending_restart)
|
||||||
mock_logger.warning.assert_called_once()
|
mock_logger.warning.assert_called_once()
|
||||||
self.assertEqual(mock_logger.warning.call_args[0],
|
self.assertEqual(mock_logger.warning.call_args[0],
|
||||||
('%s is missing from pg_controldata output', 'max_prepared_xacts setting'))
|
('%s is missing from pg_controldata output', 'max_prepared_xacts setting'))
|
||||||
@@ -863,14 +831,7 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
self.p.config.write_recovery_conf({'pause_at_recovery_target': 'false'})
|
self.p.config.write_recovery_conf({'pause_at_recovery_target': 'false'})
|
||||||
self.assertFalse(self.p.start())
|
self.assertFalse(self.p.start())
|
||||||
mock_logger.warning.assert_not_called()
|
mock_logger.warning.assert_not_called()
|
||||||
self.assertEqual(self.p.pending_restart_reason, CaseInsensitiveDict({
|
self.assertTrue(self.p.pending_restart)
|
||||||
'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.exists', Mock(return_value=True))
|
||||||
@patch('os.path.isfile', Mock(return_value=False))
|
@patch('os.path.isfile', Mock(return_value=False))
|
||||||
@@ -1065,7 +1026,7 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
def test__read_postgres_gucs_validators_file(self):
|
def test__read_postgres_gucs_validators_file(self):
|
||||||
# raise exception
|
# raise exception
|
||||||
with self.assertRaises(InvalidGucValidatorsFile) as exc:
|
with self.assertRaises(InvalidGucValidatorsFile) as exc:
|
||||||
_read_postgres_gucs_validators_file(Path('random_file.yaml'))
|
_read_postgres_gucs_validators_file('random_file.yaml')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
str(exc.exception),
|
str(exc.exception),
|
||||||
"Unexpected issue while reading parameters file `random_file.yaml`: `[Errno 2] No such file or directory: "
|
"Unexpected issue while reading parameters file `random_file.yaml`: `[Errno 2] No such file or directory: "
|
||||||
@@ -1074,32 +1035,17 @@ class TestPostgresql(BaseTestPostgresql):
|
|||||||
|
|
||||||
def test__load_postgres_gucs_validators(self):
|
def test__load_postgres_gucs_validators(self):
|
||||||
# log messages
|
# log messages
|
||||||
file1_attrs = {'is_file.return_value': True, 'is_dir.return_value': False}
|
with patch('os.walk', Mock(return_value=iter([('.', [], ['file.txt', 'random.yaml'])]))), \
|
||||||
file1_mock = MagicMock(**file1_attrs)
|
patch('patroni.postgresql.validator.logger.info') as mock_info, \
|
||||||
file1_mock.name = '__init__.py'
|
|
||||||
file2_attrs = {'is_file.return_value': False, 'is_dir.return_value': True, 'iterdir.return_value': []}
|
|
||||||
file2_mock = MagicMock(**file2_attrs)
|
|
||||||
file2_mock.name = '__pycache__'
|
|
||||||
file3_attrs = {'is_file.return_value': True, 'is_dir.return_value': False}
|
|
||||||
file3_mock = MagicMock(**file3_attrs)
|
|
||||||
file3_mock.name = file3_mock.__str__.return_value = 'random.yaml'
|
|
||||||
file3_mock.open.side_effect = FileNotFoundError('[Errno 2] No such file or directory: random.yaml')
|
|
||||||
file4_attrs = {'is_file.return_value': True, 'is_dir.return_value': False}
|
|
||||||
file4_mock = MagicMock(**file4_attrs)
|
|
||||||
file4_mock.name = 'file.txt'
|
|
||||||
dir_attrs = {'name': 'available_parameters', 'is_file.return_value': False, 'is_dir.return_value': True}
|
|
||||||
dir_mock = MagicMock(**dir_attrs)
|
|
||||||
dir_mock.iterdir.return_value = [file1_mock, file2_mock, file3_mock, file4_mock]
|
|
||||||
with patch('patroni.postgresql.available_parameters.conf_dir', dir_mock), \
|
|
||||||
patch('patroni.postgresql.available_parameters.logger.info') as mock_info, \
|
|
||||||
patch('patroni.postgresql.validator.logger.warning') as mock_warning:
|
patch('patroni.postgresql.validator.logger.warning') as mock_warning:
|
||||||
_load_postgres_gucs_validators()
|
_load_postgres_gucs_validators()
|
||||||
mock_info.assert_called_once_with('Ignored a non-YAML file found under `%s` '
|
mock_info.assert_called_once_with('Ignored a non-YAML file found under `available_parameters` directory: '
|
||||||
'directory: `%s`.', 'available_parameters', file4_mock)
|
'`%s`.', os.path.join('.', 'file.txt'))
|
||||||
mock_warning.assert_called_once()
|
mock_warning.assert_called_once()
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"Unexpected issue while reading parameters file `random.yaml`: `[Errno 2] No such file or "
|
"Unexpected issue while reading parameters file `{0}`: `[Errno 2] No such file or "
|
||||||
"directory:", mock_warning.call_args[0][0]
|
"directory:".format(os.path.join('.', 'random.yaml')),
|
||||||
|
mock_warning.call_args[0][0]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,473 +0,0 @@
|
|||||||
import unittest
|
|
||||||
|
|
||||||
from typing import List, Set, Tuple
|
|
||||||
|
|
||||||
from patroni.quorum import QuorumStateResolver, QuorumError
|
|
||||||
|
|
||||||
|
|
||||||
class QuorumTest(unittest.TestCase):
|
|
||||||
|
|
||||||
def check_state_transitions(self, leader: str, quorum: int, voters: Set[str], numsync: int, sync: Set[str],
|
|
||||||
numsync_confirmed: int, active: Set[str], sync_wanted: int, leader_wanted: str,
|
|
||||||
expected: List[Tuple[str, str, int, Set[str]]]) -> None:
|
|
||||||
kwargs = {
|
|
||||||
'leader': leader, 'quorum': quorum, 'voters': voters,
|
|
||||||
'numsync': numsync, 'sync': sync, 'numsync_confirmed': numsync_confirmed,
|
|
||||||
'active': active, 'sync_wanted': sync_wanted, 'leader_wanted': leader_wanted
|
|
||||||
}
|
|
||||||
result = list(QuorumStateResolver(**kwargs))
|
|
||||||
self.assertEqual(result, expected)
|
|
||||||
|
|
||||||
# also check interrupted transitions
|
|
||||||
if len(result) > 0 and result[0][0] != 'restart' and kwargs['leader'] == result[0][1]:
|
|
||||||
if result[0][0] == 'sync':
|
|
||||||
kwargs.update(numsync=result[0][2], sync=result[0][3])
|
|
||||||
else:
|
|
||||||
kwargs.update(leader=result[0][1], quorum=result[0][2], voters=result[0][3])
|
|
||||||
kwargs['expected'] = expected[1:]
|
|
||||||
self.check_state_transitions(**kwargs)
|
|
||||||
|
|
||||||
def test_1111(self):
|
|
||||||
leader = 'a'
|
|
||||||
|
|
||||||
# Add node
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set(),
|
|
||||||
numsync=0, sync=set(), numsync_confirmed=0, active=set('b'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 1, set('b')),
|
|
||||||
('restart', leader, 0, set()),
|
|
||||||
])
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set(),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('b'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set('b'))
|
|
||||||
])
|
|
||||||
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set(),
|
|
||||||
numsync=0, sync=set(), numsync_confirmed=0, active=set('bcde'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 2, set('bcde')),
|
|
||||||
('restart', leader, 0, set()),
|
|
||||||
])
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set(),
|
|
||||||
numsync=2, sync=set('bcde'), numsync_confirmed=1, active=set('bcde'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 3, set('bcde')),
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_1222(self):
|
|
||||||
"""2 node cluster"""
|
|
||||||
leader = 'a'
|
|
||||||
|
|
||||||
# Active set matches state
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('b'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[])
|
|
||||||
|
|
||||||
# Add node by increasing quorum
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('BC'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 1, set('bC')),
|
|
||||||
('sync', leader, 1, set('bC')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Add node by increasing sync
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('bc'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 2, set('bc')),
|
|
||||||
('quorum', leader, 1, set('bc')),
|
|
||||||
])
|
|
||||||
# Reduce quorum after added node caught up
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bc'),
|
|
||||||
numsync=2, sync=set('bc'), numsync_confirmed=2, active=set('bc'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set('bc')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Add multiple nodes by increasing both sync and quorum
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('BCdE'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 2, set('bC')),
|
|
||||||
('quorum', leader, 3, set('bCdE')),
|
|
||||||
('sync', leader, 2, set('bCdE')),
|
|
||||||
])
|
|
||||||
# Reduce quorum after added nodes caught up
|
|
||||||
self.check_state_transitions(leader=leader, quorum=3, voters=set('bcde'),
|
|
||||||
numsync=2, sync=set('bcde'), numsync_confirmed=3, active=set('bcde'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 2, set('bcde')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Primary is alone
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=0, active=set(),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set()),
|
|
||||||
('sync', leader, 0, set()),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Swap out sync replica
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=0, active=set('c'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set()),
|
|
||||||
('sync', leader, 1, set('c')),
|
|
||||||
('restart', leader, 0, set()),
|
|
||||||
])
|
|
||||||
# Update quorum when added node caught up
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set(),
|
|
||||||
numsync=1, sync=set('c'), numsync_confirmed=1, active=set('c'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set('c')),
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_1233(self):
|
|
||||||
"""Interrupted transition from 2 node cluster to 3 node fully sync cluster"""
|
|
||||||
leader = 'a'
|
|
||||||
|
|
||||||
# Node c went away, transition back to 2 node cluster
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=2, sync=set('bc'), numsync_confirmed=1, active=set('b'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 1, set('b')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Node c is available transition to larger quorum set, but not yet caught up.
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=2, sync=set('bc'), numsync_confirmed=1, active=set('bc'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 1, set('bc')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Add in a new node at the same time, but node c didn't caught up yet
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=2, sync=set('bc'), numsync_confirmed=1, active=set('bcd'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 2, set('bcd')),
|
|
||||||
('sync', leader, 2, set('bcd')),
|
|
||||||
])
|
|
||||||
# All sync nodes caught up, reduce quorum
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcd'),
|
|
||||||
numsync=2, sync=set('bcd'), numsync_confirmed=3, active=set('bcd'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 1, set('bcd')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Change replication factor at the same time
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('b'),
|
|
||||||
numsync=2, sync=set('bc'), numsync_confirmed=1, active=set('bc'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 1, set('bc')),
|
|
||||||
('sync', leader, 1, set('bc')),
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_2322(self):
|
|
||||||
"""Interrupted transition from 2 node cluster to 3 node cluster with replication factor 2"""
|
|
||||||
leader = 'a'
|
|
||||||
|
|
||||||
# Node c went away, transition back to 2 node cluster
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bc'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('b'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set('b')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Node c is available transition to larger quorum set.
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bc'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('bc'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 1, set('bc')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Add in a new node at the same time
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bc'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('bcd'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 1, set('bc')),
|
|
||||||
('quorum', leader, 2, set('bcd')),
|
|
||||||
('sync', leader, 1, set('bcd')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Convert to a fully synced cluster
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bc'),
|
|
||||||
numsync=1, sync=set('b'), numsync_confirmed=1, active=set('bc'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 2, set('bc')),
|
|
||||||
])
|
|
||||||
# Reduce quorum after all nodes caught up
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bc'),
|
|
||||||
numsync=2, sync=set('bc'), numsync_confirmed=2, active=set('bc'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set('bc')),
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_3535(self):
|
|
||||||
leader = 'a'
|
|
||||||
|
|
||||||
# remove nodes
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcde'),
|
|
||||||
numsync=2, sync=set('bcde'), numsync_confirmed=2, active=set('bc'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 2, set('bc')),
|
|
||||||
('quorum', leader, 0, set('bc')),
|
|
||||||
])
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcde'),
|
|
||||||
numsync=2, sync=set('bcde'), numsync_confirmed=3, active=set('bcd'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 2, set('bcd')),
|
|
||||||
('quorum', leader, 1, set('bcd')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# remove nodes and decrease sync
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcde'),
|
|
||||||
numsync=2, sync=set('bcde'), numsync_confirmed=2, active=set('bc'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 2, set('bc')),
|
|
||||||
('quorum', leader, 1, set('bc')),
|
|
||||||
('sync', leader, 1, set('bc')),
|
|
||||||
])
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bcde'),
|
|
||||||
numsync=3, sync=set('bcde'), numsync_confirmed=2, active=set('bc'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 3, set('bcd')),
|
|
||||||
('quorum', leader, 1, set('bc')),
|
|
||||||
('sync', leader, 1, set('bc')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Increase replication factor and decrease quorum
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcde'),
|
|
||||||
numsync=2, sync=set('bcde'), numsync_confirmed=2, active=set('bcde'),
|
|
||||||
sync_wanted=3, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 3, set('bcde')),
|
|
||||||
])
|
|
||||||
# decrease quorum after more nodes caught up
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcde'),
|
|
||||||
numsync=3, sync=set('bcde'), numsync_confirmed=3, active=set('bcde'),
|
|
||||||
sync_wanted=3, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 1, set('bcde')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Add node with decreasing sync and increasing quorum
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcde'),
|
|
||||||
numsync=2, sync=set('bcde'), numsync_confirmed=2, active=set('bcdef'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
# increase quorum by 2, 1 for added node and another for reduced sync
|
|
||||||
('quorum', leader, 4, set('bcdef')),
|
|
||||||
# now reduce replication factor to requested value
|
|
||||||
('sync', leader, 1, set('bcdef')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Remove node with increasing sync and decreasing quorum
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcde'),
|
|
||||||
numsync=2, sync=set('bcde'), numsync_confirmed=2, active=set('bcd'),
|
|
||||||
sync_wanted=3, leader_wanted=leader, expected=[
|
|
||||||
# node e removed from sync wth replication factor increase
|
|
||||||
('sync', leader, 3, set('bcd')),
|
|
||||||
# node e removed from voters with quorum decrease
|
|
||||||
('quorum', leader, 1, set('bcd')),
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_remove_nosync_node(self):
|
|
||||||
leader = 'a'
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('bc'),
|
|
||||||
numsync=2, sync=set('bc'), numsync_confirmed=1, active=set('b'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set('b')),
|
|
||||||
('sync', leader, 1, set('b'))
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_swap_sync_node(self):
|
|
||||||
leader = 'a'
|
|
||||||
self.check_state_transitions(leader=leader, quorum=0, voters=set('bc'),
|
|
||||||
numsync=2, sync=set('bc'), numsync_confirmed=1, active=set('bd'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set('b')),
|
|
||||||
('sync', leader, 2, set('bd')),
|
|
||||||
('quorum', leader, 1, set('bd'))
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_promotion(self):
|
|
||||||
# Beginning stat: 'a' in the primary, 1 of bcd in sync
|
|
||||||
# a fails, c gets quorum votes and promotes
|
|
||||||
self.check_state_transitions(leader='a', quorum=2, voters=set('bcd'),
|
|
||||||
numsync=0, sync=set(), numsync_confirmed=0, active=set(),
|
|
||||||
sync_wanted=1, leader_wanted='c', expected=[
|
|
||||||
('sync', 'a', 1, set('abd')), # set a and b to sync
|
|
||||||
('quorum', 'c', 2, set('abd')), # set c as a leader and move a to voters
|
|
||||||
# and stop because there are no active nodes
|
|
||||||
])
|
|
||||||
|
|
||||||
# next loop, b managed to reconnect
|
|
||||||
self.check_state_transitions(leader='c', quorum=2, voters=set('abd'),
|
|
||||||
numsync=1, sync=set('abd'), numsync_confirmed=0, active=set('b'),
|
|
||||||
sync_wanted=1, leader_wanted='c', expected=[
|
|
||||||
('sync', 'c', 1, set('b')), # remove a from sync as inactive
|
|
||||||
('quorum', 'c', 0, set('b')), # remove a from voters and reduce quorum
|
|
||||||
])
|
|
||||||
|
|
||||||
# alternative reality: next loop, no one reconnected
|
|
||||||
self.check_state_transitions(leader='c', quorum=2, voters=set('abd'),
|
|
||||||
numsync=1, sync=set('abd'), numsync_confirmed=0, active=set(),
|
|
||||||
sync_wanted=1, leader_wanted='c', expected=[
|
|
||||||
('quorum', 'c', 0, set()),
|
|
||||||
('sync', 'c', 0, set()),
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_nonsync_promotion(self):
|
|
||||||
# Beginning state: 1 of bc in sync. e.g. (a primary, ssn = ANY 1 (b c))
|
|
||||||
# a fails, d sees b and c, knows that it is in sync and decides to promote.
|
|
||||||
# We include in sync state former primary increasing replication factor
|
|
||||||
# and let situation resolve. Node d ssn=ANY 1 (b c)
|
|
||||||
leader = 'd'
|
|
||||||
self.check_state_transitions(leader='a', quorum=1, voters=set('bc'),
|
|
||||||
numsync=0, sync=set(), numsync_confirmed=0, active=set(),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
# Set a, b, and c to sync and increase replication factor
|
|
||||||
('sync', 'a', 2, set('abc')),
|
|
||||||
# Set ourselves as the leader and move the old leader to voters
|
|
||||||
('quorum', leader, 1, set('abc')),
|
|
||||||
# and stop because there are no active nodes
|
|
||||||
])
|
|
||||||
# next loop, b and c managed to reconnect
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('abc'),
|
|
||||||
numsync=2, sync=set('abc'), numsync_confirmed=0, active=set('bc'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('sync', leader, 2, set('bc')), # Remove a from being synced to.
|
|
||||||
('quorum', leader, 1, set('bc')), # Remove a from quorum
|
|
||||||
('sync', leader, 1, set('bc')), # Can now reduce replication factor back
|
|
||||||
])
|
|
||||||
# alternative reality: next loop, no one reconnected
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('abc'),
|
|
||||||
numsync=2, sync=set('abc'), numsync_confirmed=0, active=set(),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 0, set()),
|
|
||||||
('sync', leader, 0, set()),
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_invalid_states(self):
|
|
||||||
leader = 'a'
|
|
||||||
|
|
||||||
# Main invariant is not satisfied, system is in an unsafe state
|
|
||||||
resolver = QuorumStateResolver(leader=leader, quorum=0, voters=set('bc'),
|
|
||||||
numsync=1, sync=set('bc'), numsync_confirmed=1,
|
|
||||||
active=set('bc'), sync_wanted=1, leader_wanted=leader)
|
|
||||||
self.assertRaises(QuorumError, resolver.check_invariants)
|
|
||||||
self.assertEqual(list(resolver), [
|
|
||||||
('quorum', leader, 1, set('bc'))
|
|
||||||
])
|
|
||||||
|
|
||||||
# Quorum and sync states mismatched, somebody other than Patroni modified system state
|
|
||||||
resolver = QuorumStateResolver(leader=leader, quorum=1, voters=set('bc'),
|
|
||||||
numsync=2, sync=set('bd'), numsync_confirmed=1,
|
|
||||||
active=set('bd'), sync_wanted=1, leader_wanted=leader)
|
|
||||||
self.assertRaises(QuorumError, resolver.check_invariants)
|
|
||||||
self.assertEqual(list(resolver), [
|
|
||||||
('quorum', leader, 1, set('bd')),
|
|
||||||
('sync', leader, 1, set('bd')),
|
|
||||||
])
|
|
||||||
self.assertTrue(repr(resolver.sync).startswith('<CaseInsensitiveSet'))
|
|
||||||
|
|
||||||
def test_sync_high_quorum_low_safety_margin_high(self):
|
|
||||||
leader = 'a'
|
|
||||||
|
|
||||||
self.check_state_transitions(leader=leader, quorum=2, voters=set('bcdef'),
|
|
||||||
numsync=4, sync=set('bcdef'), numsync_confirmed=3, active=set('bcdef'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
('quorum', leader, 3, set('bcdef')), # Adjust quorum requirements
|
|
||||||
('sync', leader, 2, set('bcdef')), # Reduce synchronization
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_quorum_update(self):
|
|
||||||
resolver = QuorumStateResolver(leader='a', quorum=1, voters=set('bc'), numsync=1, sync=set('bc'),
|
|
||||||
numsync_confirmed=1, active=set('bc'), sync_wanted=1, leader_wanted='a')
|
|
||||||
self.assertRaises(QuorumError, list, resolver.quorum_update(-1, set()))
|
|
||||||
self.assertRaises(QuorumError, list, resolver.quorum_update(1, set()))
|
|
||||||
|
|
||||||
def test_sync_update(self):
|
|
||||||
resolver = QuorumStateResolver(leader='a', quorum=1, voters=set('bc'), numsync=1, sync=set('bc'),
|
|
||||||
numsync_confirmed=1, active=set('bc'), sync_wanted=1, leader_wanted='a')
|
|
||||||
self.assertRaises(QuorumError, list, resolver.sync_update(-1, set()))
|
|
||||||
self.assertRaises(QuorumError, list, resolver.sync_update(1, set()))
|
|
||||||
|
|
||||||
def test_remove_nodes_with_decreasing_sync(self):
|
|
||||||
leader = 'a'
|
|
||||||
|
|
||||||
# Remove node with decreasing sync
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bcdef'),
|
|
||||||
numsync=4, sync=set('bcdef'), numsync_confirmed=2, active=set('bcd'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
# node f removed from sync
|
|
||||||
('sync', leader, 4, set('bcde')),
|
|
||||||
# nodes e and f removed from voters with quorum decrease
|
|
||||||
('quorum', leader, 1, set('bcd')),
|
|
||||||
# node e removed from sync with replication factor decrease
|
|
||||||
('sync', leader, 2, set('bcd')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Interrupted state, and node g joined
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bcdef'),
|
|
||||||
numsync=4, sync=set('bcde'), numsync_confirmed=2, active=set('bcdg'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
# remove nodes e and f from voters
|
|
||||||
('quorum', leader, 1, set('bcd')),
|
|
||||||
# remove node e from sync and reduce replication factor
|
|
||||||
('sync', leader, 3, set('bcd')),
|
|
||||||
# add node g to voters with quorum increase
|
|
||||||
('quorum', leader, 2, set('bcdg')),
|
|
||||||
# add node g to sync and reduce replication factor
|
|
||||||
('sync', leader, 2, set('bcdg')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# node f returned
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bcdef'),
|
|
||||||
numsync=4, sync=set('bcde'), numsync_confirmed=2, active=set('bcdf'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
# replace node e with f in sync
|
|
||||||
('sync', leader, 4, set('bcdf')),
|
|
||||||
# remove nodes e from voters with quorum decrease
|
|
||||||
('quorum', leader, 2, set('bcdf')),
|
|
||||||
# reduce replication factor as it was requested
|
|
||||||
('sync', leader, 2, set('bcdf')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# node e returned
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bcdef'),
|
|
||||||
numsync=4, sync=set('bcde'), numsync_confirmed=2, active=set('bcde'),
|
|
||||||
sync_wanted=2, leader_wanted=leader, expected=[
|
|
||||||
# remove nodes f from voters with quorum decrease
|
|
||||||
('quorum', leader, 2, set('bcde')),
|
|
||||||
# reduce replication factor as it was requested
|
|
||||||
('sync', leader, 2, set('bcde')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# node b is also lost
|
|
||||||
self.check_state_transitions(leader=leader, quorum=1, voters=set('bcdef'),
|
|
||||||
numsync=4, sync=set('bcde'), numsync_confirmed=2, active=set('cd'),
|
|
||||||
sync_wanted=1, leader_wanted=leader, expected=[
|
|
||||||
# remove nodes b, e, and f from voters
|
|
||||||
('quorum', leader, 1, set('cd')),
|
|
||||||
# remove nodes b and e from sync with replication factor decrease
|
|
||||||
('sync', leader, 1, set('cd')),
|
|
||||||
])
|
|
||||||
|
|
||||||
def test_empty_ssn(self):
|
|
||||||
# Beginning stat: 'a' in the primary, 1 of bc in sync
|
|
||||||
# a fails, c gets quorum votes and promotes
|
|
||||||
self.check_state_transitions(leader='a', quorum=1, voters=set('bc'),
|
|
||||||
numsync=1, sync=set(), numsync_confirmed=0, active=set(),
|
|
||||||
sync_wanted=1, leader_wanted='c', expected=[
|
|
||||||
('sync', 'a', 1, set('ab')), # remove a from sync as inactive
|
|
||||||
('quorum', 'c', 1, set('ab')), # set c as a leader and move a to voters
|
|
||||||
# and stop because there are no active nodes
|
|
||||||
])
|
|
||||||
|
|
||||||
# next loop, b managed to reconnect
|
|
||||||
self.check_state_transitions(leader='c', quorum=1, voters=set('ab'),
|
|
||||||
numsync=1, sync=set('ab'), numsync_confirmed=0, active=set('b'),
|
|
||||||
sync_wanted=1, leader_wanted='c', expected=[
|
|
||||||
('sync', 'c', 1, set('b')), # remove a from sync as inactive
|
|
||||||
('quorum', 'c', 0, set('b')), # remove a from voters and reduce quorum
|
|
||||||
])
|
|
||||||
+12
-15
@@ -4,10 +4,8 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from mock import Mock, PropertyMock, patch
|
from mock import Mock, PropertyMock, patch
|
||||||
from patroni.dcs import get_dcs
|
|
||||||
from patroni.dcs.raft import Cluster, DynMemberSyncObj, KVStoreTTL, \
|
from patroni.dcs.raft import Cluster, DynMemberSyncObj, KVStoreTTL, \
|
||||||
Raft, RaftError, SyncObjUtility, TCPTransport, _TCPTransport
|
Raft, RaftError, SyncObjUtility, TCPTransport, _TCPTransport
|
||||||
from patroni.postgresql.mpp import get_mpp
|
|
||||||
from pysyncobj import SyncObjConf, FAIL_REASON
|
from pysyncobj import SyncObjConf, FAIL_REASON
|
||||||
|
|
||||||
|
|
||||||
@@ -130,21 +128,20 @@ class TestRaft(unittest.TestCase):
|
|||||||
_TMP = tempfile.gettempdir()
|
_TMP = tempfile.gettempdir()
|
||||||
|
|
||||||
def test_raft(self):
|
def test_raft(self):
|
||||||
raft = get_dcs({'ttl': 30, 'scope': 'test', 'name': 'pg', 'retry_timeout': 10,
|
raft = Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'self_addr': '127.0.0.1:1234',
|
||||||
'raft': {'self_addr': '127.0.0.1:1234', 'data_dir': self._TMP},
|
'retry_timeout': 10, 'data_dir': self._TMP,
|
||||||
'citus': {'group': 0, 'database': 'postgres'}})
|
'database': 'citus', 'group': 0})
|
||||||
self.assertIsInstance(raft, Raft)
|
|
||||||
raft.reload_config({'retry_timeout': 20, 'ttl': 60, 'loop_wait': 10})
|
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._sync_obj.set(raft.members_path + 'legacy', '{"version":"2.0.0"}'))
|
||||||
self.assertTrue(raft.touch_member(''))
|
self.assertTrue(raft.touch_member(''))
|
||||||
self.assertTrue(raft.initialize())
|
self.assertTrue(raft.initialize())
|
||||||
self.assertTrue(raft.cancel_initialization())
|
self.assertTrue(raft.cancel_initialization())
|
||||||
self.assertTrue(raft.set_config_value('{}'))
|
self.assertTrue(raft.set_config_value('{}'))
|
||||||
self.assertTrue(raft.write_sync_state('foo', 'bar', 0))
|
self.assertTrue(raft.write_sync_state('foo', 'bar'))
|
||||||
self.assertFalse(raft.write_sync_state('foo', 'bar', 0, 1))
|
self.assertFalse(raft.write_sync_state('foo', 'bar', 1))
|
||||||
raft._mpp = get_mpp({'citus': {'group': 1, 'database': 'postgres'}})
|
raft._citus_group = '1'
|
||||||
self.assertTrue(raft.manual_failover('foo', 'bar'))
|
self.assertTrue(raft.manual_failover('foo', 'bar'))
|
||||||
raft._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
raft._citus_group = '0'
|
||||||
self.assertTrue(raft.take_leader())
|
self.assertTrue(raft.take_leader())
|
||||||
cluster = raft.get_cluster()
|
cluster = raft.get_cluster()
|
||||||
self.assertIsInstance(cluster, Cluster)
|
self.assertIsInstance(cluster, Cluster)
|
||||||
@@ -156,13 +153,13 @@ class TestRaft(unittest.TestCase):
|
|||||||
self.assertTrue(raft.update_leader(leader, '1', failsafe={'foo': 'bat'}))
|
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.failsafe_path, '{"foo"}'))
|
||||||
self.assertTrue(raft._sync_obj.set(raft.status_path, '{'))
|
self.assertTrue(raft._sync_obj.set(raft.status_path, '{'))
|
||||||
raft.get_mpp_coordinator()
|
raft.get_citus_coordinator()
|
||||||
self.assertTrue(raft.delete_sync_state())
|
self.assertTrue(raft.delete_sync_state())
|
||||||
self.assertTrue(raft.set_history_value(''))
|
self.assertTrue(raft.set_history_value(''))
|
||||||
self.assertTrue(raft.delete_cluster())
|
self.assertTrue(raft.delete_cluster())
|
||||||
raft._mpp = get_mpp({'citus': {'group': 1, 'database': 'postgres'}})
|
raft._citus_group = '1'
|
||||||
self.assertTrue(raft.delete_cluster())
|
self.assertTrue(raft.delete_cluster())
|
||||||
raft._mpp = get_mpp({})
|
raft._citus_group = None
|
||||||
raft.get_cluster()
|
raft.get_cluster()
|
||||||
raft.watch(None, 0.001)
|
raft.watch(None, 0.001)
|
||||||
raft._sync_obj.destroy()
|
raft._sync_obj.destroy()
|
||||||
@@ -178,5 +175,5 @@ class TestRaft(unittest.TestCase):
|
|||||||
def test_init(self, mock_event, mock_kvstore):
|
def test_init(self, mock_event, mock_kvstore):
|
||||||
mock_kvstore.return_value.applied_local_log = False
|
mock_kvstore.return_value.applied_local_log = False
|
||||||
mock_event.return_value.is_set.side_effect = [False, True]
|
mock_event.return_value.is_set.side_effect = [False, True]
|
||||||
self.assertIsInstance(get_dcs({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
|
self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
|
||||||
'raft': {'self_addr': '1', 'data_dir': self._TMP}}), Raft)
|
'self_addr': '1', 'data_dir': self._TMP}))
|
||||||
|
|||||||
@@ -98,8 +98,7 @@ class TestRewind(BaseTestPostgresql):
|
|||||||
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
|
self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
|
||||||
|
|
||||||
@patch.object(CancellableSubprocess, 'call', mock_cancellable_call)
|
@patch.object(CancellableSubprocess, 'call', mock_cancellable_call)
|
||||||
@patch.object(Postgresql, 'get_guc_value', Mock(return_value=''))
|
@patch.object(Postgresql, 'checkpoint', side_effect=['', '1'],)
|
||||||
@patch.object(Postgresql, 'checkpoint', side_effect=['', '1'])
|
|
||||||
@patch.object(Postgresql, 'stop', Mock(return_value=False))
|
@patch.object(Postgresql, 'stop', Mock(return_value=False))
|
||||||
@patch.object(Postgresql, 'start', Mock())
|
@patch.object(Postgresql, 'start', Mock())
|
||||||
def test_execute(self, mock_checkpoint):
|
def test_execute(self, mock_checkpoint):
|
||||||
|
|||||||
+27
-101
@@ -6,23 +6,16 @@ import unittest
|
|||||||
from mock import Mock, PropertyMock, patch
|
from mock import Mock, PropertyMock, patch
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
from patroni import global_config, psycopg
|
from patroni import psycopg
|
||||||
|
from patroni.config import GlobalConfig
|
||||||
from patroni.dcs import Cluster, ClusterConfig, Member, Status, SyncState
|
from patroni.dcs import Cluster, ClusterConfig, Member, Status, SyncState
|
||||||
from patroni.postgresql import Postgresql
|
from patroni.postgresql import Postgresql
|
||||||
from patroni.postgresql.misc import fsync_dir
|
from patroni.postgresql.misc import fsync_dir
|
||||||
from patroni.postgresql.slots import SlotsAdvanceThread, SlotsHandler
|
from patroni.postgresql.slots import SlotsAdvanceThread, SlotsHandler
|
||||||
from patroni.tags import Tags
|
|
||||||
|
|
||||||
from . import BaseTestPostgresql, psycopg_connect, MockCursor
|
from . import BaseTestPostgresql, psycopg_connect, MockCursor
|
||||||
|
|
||||||
|
|
||||||
class TestTags(Tags):
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tags(self):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
@patch('subprocess.call', Mock(return_value=0))
|
@patch('subprocess.call', Mock(return_value=0))
|
||||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||||
@patch.object(Thread, 'start', Mock())
|
@patch.object(Thread, 'start', Mock())
|
||||||
@@ -36,13 +29,12 @@ class TestSlotsHandler(BaseTestPostgresql):
|
|||||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestSlotsHandler, self).setUp()
|
super(TestSlotsHandler, self).setUp()
|
||||||
|
self.p._global_config = GlobalConfig({})
|
||||||
self.s = self.p.slots_handler
|
self.s = self.p.slots_handler
|
||||||
self.p.start()
|
self.p.start()
|
||||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}, 'ls2': None}}, 1)
|
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}, 'ls2': None}}, 1)
|
||||||
self.cluster = Cluster(True, config, self.leader, Status(0, {'ls': 12345, 'ls2': 12345}),
|
self.cluster = Cluster(True, config, self.leader, Status(0, {'ls': 12345, 'ls2': 12345}),
|
||||||
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
|
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
|
||||||
global_config.update(self.cluster)
|
|
||||||
self.tags = TestTags()
|
|
||||||
|
|
||||||
def test_sync_replication_slots(self):
|
def test_sync_replication_slots(self):
|
||||||
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
|
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
|
||||||
@@ -50,38 +42,36 @@ class TestSlotsHandler(BaseTestPostgresql):
|
|||||||
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
||||||
cluster = Cluster(True, config, self.leader, Status(0, {'test_3': 10}),
|
cluster = Cluster(True, config, self.leader, Status(0, {'test_3': 10}),
|
||||||
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
|
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
|
||||||
global_config.update(cluster)
|
|
||||||
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg.OperationalError)):
|
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg.OperationalError)):
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
self.p.set_role('standby_leader')
|
self.p.set_role('standby_leader')
|
||||||
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=(True, False))), \
|
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=(True, False))), \
|
||||||
patch.object(global_config.__class__, 'is_standby_cluster', PropertyMock(return_value=True)), \
|
patch.object(GlobalConfig, 'is_standby_cluster', PropertyMock(return_value=True)), \
|
||||||
patch('patroni.postgresql.slots.logger.debug') as mock_debug:
|
patch('patroni.postgresql.slots.logger.debug') as mock_debug:
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
mock_debug.assert_called_once()
|
mock_debug.assert_called_once()
|
||||||
self.p.set_role('replica')
|
self.p.set_role('replica')
|
||||||
with patch.object(Postgresql, 'is_primary', Mock(return_value=False)), \
|
with patch.object(Postgresql, 'is_primary', Mock(return_value=False)), \
|
||||||
patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)), \
|
|
||||||
patch.object(SlotsHandler, 'drop_replication_slot') as mock_drop:
|
patch.object(SlotsHandler, 'drop_replication_slot') as mock_drop:
|
||||||
config.data['slots'].pop('ls')
|
config.data['slots'].pop('ls')
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False, paused=True)
|
||||||
mock_drop.assert_not_called()
|
mock_drop.assert_not_called()
|
||||||
self.p.set_role('primary')
|
self.p.set_role('primary')
|
||||||
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
|
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
with patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
|
with patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
|
||||||
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||||
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
||||||
cluster.members.extend([alias1, alias2])
|
cluster.members.extend([alias1, alias2])
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
self.assertEqual(errorlog_mock.call_count, 5)
|
self.assertEqual(errorlog_mock.call_count, 5)
|
||||||
ca = errorlog_mock.call_args_list[0][0][1]
|
ca = errorlog_mock.call_args_list[0][0][1]
|
||||||
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
|
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
|
||||||
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
|
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
|
||||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90618)):
|
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90618)):
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
self.p.set_role('replica')
|
self.p.set_role('replica')
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
|
|
||||||
def test_cascading_replica_sync_replication_slots(self):
|
def test_cascading_replica_sync_replication_slots(self):
|
||||||
"""Test sync with a cascading replica so physical slots are present on a replica."""
|
"""Test sync with a cascading replica so physical slots are present on a replica."""
|
||||||
@@ -96,7 +86,7 @@ class TestSlotsHandler(BaseTestPostgresql):
|
|||||||
with patch.object(Postgresql, '_query') as mock_query, \
|
with patch.object(Postgresql, '_query') as mock_query, \
|
||||||
patch.object(Postgresql, 'is_primary', Mock(return_value=False)):
|
patch.object(Postgresql, 'is_primary', Mock(return_value=False)):
|
||||||
mock_query.return_value = [('ls', 'logical', 104, 'b', 'a', 5, 12345, 105)]
|
mock_query.return_value = [('ls', 'logical', 104, 'b', 'a', 5, 12345, 105)]
|
||||||
ret = self.s.sync_replication_slots(cluster, self.tags)
|
ret = self.s.sync_replication_slots(cluster, False)
|
||||||
self.assertEqual(ret, [])
|
self.assertEqual(ret, [])
|
||||||
|
|
||||||
def test_process_permanent_slots(self):
|
def test_process_permanent_slots(self):
|
||||||
@@ -104,9 +94,8 @@ class TestSlotsHandler(BaseTestPostgresql):
|
|||||||
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
||||||
cluster = Cluster(True, config, self.leader, Status.empty(), [self.me, self.other, self.leadermem],
|
cluster = Cluster(True, config, self.leader, Status.empty(), [self.me, self.other, self.leadermem],
|
||||||
None, SyncState.empty(), None, None)
|
None, SyncState.empty(), None, None)
|
||||||
global_config.update(cluster)
|
|
||||||
|
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
with patch.object(Postgresql, '_query') as mock_query:
|
with patch.object(Postgresql, '_query') as mock_query:
|
||||||
self.p.reset_cluster_info_state(None)
|
self.p.reset_cluster_info_state(None)
|
||||||
mock_query.return_value = [(
|
mock_query.return_value = [(
|
||||||
@@ -124,115 +113,53 @@ class TestSlotsHandler(BaseTestPostgresql):
|
|||||||
"confirmed_flush_lsn": 12345, "catalog_xmin": 105}])]
|
"confirmed_flush_lsn": 12345, "catalog_xmin": 105}])]
|
||||||
self.assertEqual(self.p.slots(), {})
|
self.assertEqual(self.p.slots(), {})
|
||||||
|
|
||||||
def test_nostream_slot_processing(self):
|
|
||||||
config = ClusterConfig(
|
|
||||||
1, {'slots': {'foo': {'type': 'logical', 'database': 'a', 'plugin': 'b'}, 'bar': {'type': 'physical'}}}, 1)
|
|
||||||
nostream_node = Member(0, 'test-2', 28, {
|
|
||||||
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
|
|
||||||
'tags': {'nostream': 'True'}
|
|
||||||
})
|
|
||||||
cascade_node = Member(0, 'test-3', 28, {
|
|
||||||
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
|
|
||||||
'tags': {'replicatefrom': 'test-2'}
|
|
||||||
})
|
|
||||||
stream_node = Member(0, 'test-4', 28, {
|
|
||||||
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
|
|
||||||
cluster = Cluster(
|
|
||||||
True, config, self.leader, Status.empty(),
|
|
||||||
[self.leadermem, nostream_node, cascade_node, stream_node], None, SyncState.empty(), None, None)
|
|
||||||
global_config.update(cluster)
|
|
||||||
|
|
||||||
# sanity for primary
|
|
||||||
self.p.name = self.leadermem.name
|
|
||||||
self.assertEqual(
|
|
||||||
cluster._get_permanent_slots(self.p, self.leadermem, 'primary'),
|
|
||||||
{'foo': {'type': 'logical', 'database': 'a', 'plugin': 'b'}, 'bar': {'type': 'physical'}})
|
|
||||||
self.assertEqual(
|
|
||||||
cluster._get_members_slots(self.p.name, 'primary'),
|
|
||||||
{'test_4': {'type': 'physical'}})
|
|
||||||
|
|
||||||
# nostream node must not have slot on primary
|
|
||||||
self.p.name = nostream_node.name
|
|
||||||
# permanent logical slots are not allowed on nostream node
|
|
||||||
self.assertEqual(
|
|
||||||
cluster._get_permanent_slots(self.p, nostream_node, 'replica'),
|
|
||||||
{'bar': {'type': 'physical'}})
|
|
||||||
self.assertEqual(
|
|
||||||
cluster.get_slot_name_on_primary(self.p.name, nostream_node),
|
|
||||||
None)
|
|
||||||
|
|
||||||
# check cascade member-slot existence on nostream node
|
|
||||||
self.assertEqual(
|
|
||||||
cluster._get_members_slots(nostream_node.name, 'replica'),
|
|
||||||
{'test_3': {'type': 'physical'}})
|
|
||||||
|
|
||||||
# cascade also does not entitled to have logical slot on itself ...
|
|
||||||
self.p.name = cascade_node.name
|
|
||||||
self.assertEqual(
|
|
||||||
cluster._get_permanent_slots(self.p, cascade_node, 'replica'),
|
|
||||||
{'bar': {'type': 'physical'}})
|
|
||||||
# ... and member-slot on primary
|
|
||||||
self.assertEqual(
|
|
||||||
cluster.get_slot_name_on_primary(self.p.name, cascade_node),
|
|
||||||
None)
|
|
||||||
|
|
||||||
# simple replica must have every permanent slot ...
|
|
||||||
self.p.name = stream_node.name
|
|
||||||
self.assertEqual(
|
|
||||||
cluster._get_permanent_slots(self.p, stream_node, 'replica'),
|
|
||||||
{'foo': {'type': 'logical', 'database': 'a', 'plugin': 'b'}, 'bar': {'type': 'physical'}})
|
|
||||||
# ... and member-slot on primary
|
|
||||||
self.assertEqual(
|
|
||||||
cluster.get_slot_name_on_primary(self.p.name, stream_node),
|
|
||||||
'test_4')
|
|
||||||
|
|
||||||
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
||||||
def test__ensure_logical_slots_replica(self):
|
def test__ensure_logical_slots_replica(self):
|
||||||
self.p.set_role('replica')
|
self.p.set_role('replica')
|
||||||
self.cluster.slots['ls'] = 12346
|
self.cluster.slots['ls'] = 12346
|
||||||
with patch.object(SlotsHandler, 'check_logical_slots_readiness', Mock(return_value=False)):
|
with patch.object(SlotsHandler, 'check_logical_slots_readiness', Mock(return_value=False)):
|
||||||
self.assertEqual(self.s.sync_replication_slots(self.cluster, self.tags), [])
|
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), [])
|
||||||
with patch.object(SlotsHandler, '_query', Mock(return_value=[('ls', 'logical', 499, 'b', 'a', 5, 100, 500)])), \
|
with patch.object(SlotsHandler, '_query', Mock(return_value=[('ls', 'logical', 499, 'b', 'a', 5, 100, 500)])), \
|
||||||
patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)), \
|
patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)), \
|
||||||
patch.object(SlotsAdvanceThread, 'schedule', Mock(return_value=(True, ['ls']))), \
|
patch.object(SlotsAdvanceThread, 'schedule', Mock(return_value=(True, ['ls']))), \
|
||||||
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
|
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
|
||||||
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
|
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
|
||||||
self.assertEqual(self.s.sync_replication_slots(self.cluster, self.tags), ['ls'])
|
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), ['ls'])
|
||||||
self.cluster.slots['ls'] = 'a'
|
self.cluster.slots['ls'] = 'a'
|
||||||
self.assertEqual(self.s.sync_replication_slots(self.cluster, self.tags), [])
|
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), [])
|
||||||
self.cluster.config.data['slots']['ls']['database'] = 'b'
|
self.cluster.config.data['slots']['ls']['database'] = 'b'
|
||||||
self.cluster.slots['ls'] = '500'
|
self.cluster.slots['ls'] = '500'
|
||||||
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
|
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
|
||||||
self.assertEqual(self.s.sync_replication_slots(self.cluster, self.tags), ['ls'])
|
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), ['ls'])
|
||||||
|
|
||||||
def test_copy_logical_slots(self):
|
def test_copy_logical_slots(self):
|
||||||
self.cluster.config.data['slots']['ls']['database'] = 'b'
|
self.cluster.config.data['slots']['ls']['database'] = 'b'
|
||||||
self.s.copy_logical_slots(self.cluster, self.tags, ['ls'])
|
self.s.copy_logical_slots(self.cluster, ['ls'])
|
||||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)):
|
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)):
|
||||||
self.s.copy_logical_slots(self.cluster, self.tags, ['foo'])
|
self.s.copy_logical_slots(self.cluster, ['foo'])
|
||||||
with patch.object(Cluster, 'leader', PropertyMock(return_value=None)):
|
with patch.object(Cluster, 'leader', PropertyMock(return_value=None)):
|
||||||
self.s.copy_logical_slots(self.cluster, self.tags, ['foo'])
|
self.s.copy_logical_slots(self.cluster, ['foo'])
|
||||||
|
|
||||||
@patch.object(Postgresql, 'stop', Mock(return_value=True))
|
@patch.object(Postgresql, 'stop', Mock(return_value=True))
|
||||||
@patch.object(Postgresql, 'start', Mock(return_value=True))
|
@patch.object(Postgresql, 'start', Mock(return_value=True))
|
||||||
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
||||||
def test_check_logical_slots_readiness(self):
|
def test_check_logical_slots_readiness(self):
|
||||||
self.s.copy_logical_slots(self.cluster, self.tags, ['ls'])
|
self.s.copy_logical_slots(self.cluster, ['ls'])
|
||||||
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
|
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
|
||||||
patch.object(MockCursor, 'fetchall', Mock(side_effect=Exception)):
|
patch.object(MockCursor, 'fetchall', Mock(side_effect=Exception)):
|
||||||
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, self.tags))
|
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, None))
|
||||||
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
|
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
|
||||||
patch.object(MockCursor, 'fetchall', Mock(return_value=[(False,)])):
|
patch.object(MockCursor, 'fetchall', Mock(return_value=[(False,)])):
|
||||||
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, self.tags))
|
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, None))
|
||||||
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('ls', 100)]))):
|
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('ls', 100)]))):
|
||||||
self.s.check_logical_slots_readiness(self.cluster, self.tags)
|
self.s.check_logical_slots_readiness(self.cluster, None)
|
||||||
|
|
||||||
@patch.object(Postgresql, 'stop', Mock(return_value=True))
|
@patch.object(Postgresql, 'stop', Mock(return_value=True))
|
||||||
@patch.object(Postgresql, 'start', Mock(return_value=True))
|
@patch.object(Postgresql, 'start', Mock(return_value=True))
|
||||||
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
||||||
def test_on_promote(self):
|
def test_on_promote(self):
|
||||||
self.s.schedule_advance_slots({'foo': {'bar': 100}})
|
self.s.schedule_advance_slots({'foo': {'bar': 100}})
|
||||||
self.s.copy_logical_slots(self.cluster, self.tags, ['ls'])
|
self.s.copy_logical_slots(self.cluster, ['ls'])
|
||||||
self.s.on_promote()
|
self.s.on_promote()
|
||||||
|
|
||||||
@unittest.skipIf(os.name == 'nt', "Windows not supported")
|
@unittest.skipIf(os.name == 'nt', "Windows not supported")
|
||||||
@@ -262,12 +189,11 @@ class TestSlotsHandler(BaseTestPostgresql):
|
|||||||
config = ClusterConfig(1, {'slots': {'blabla': {'type': 'physical'}, 'leader': None}}, 1)
|
config = ClusterConfig(1, {'slots': {'blabla': {'type': 'physical'}, 'leader': None}}, 1)
|
||||||
cluster = Cluster(True, config, self.leader, Status(0, {'blabla': 12346}),
|
cluster = Cluster(True, config, self.leader, Status(0, {'blabla': 12346}),
|
||||||
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
|
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
|
||||||
global_config.update(cluster)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
|
||||||
with patch.object(SlotsHandler, '_query', Mock(side_effect=[[('blabla', 'physical', 12345, None, None, None,
|
with patch.object(SlotsHandler, '_query', Mock(side_effect=[[('blabla', 'physical', 12345, None, None, None,
|
||||||
None, None)], Exception])) as mock_query, \
|
None, None)], Exception])) as mock_query, \
|
||||||
patch('patroni.postgresql.slots.logger.error') as mock_error:
|
patch('patroni.postgresql.slots.logger.error') as mock_error:
|
||||||
self.s.sync_replication_slots(cluster, self.tags)
|
self.s.sync_replication_slots(cluster, False)
|
||||||
self.assertEqual(mock_query.call_args[0],
|
self.assertEqual(mock_query.call_args[0],
|
||||||
("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", "blabla", '0/303A'))
|
("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", "blabla", '0/303A'))
|
||||||
self.assertEqual(mock_error.call_args[0][0],
|
self.assertEqual(mock_error.call_args[0][0],
|
||||||
|
|||||||
+13
-67
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from mock import Mock, patch
|
from mock import Mock, patch
|
||||||
|
|
||||||
from patroni import global_config
|
|
||||||
from patroni.collections import CaseInsensitiveSet
|
from patroni.collections import CaseInsensitiveSet
|
||||||
from patroni.dcs import Cluster, ClusterConfig, Status, SyncState
|
from patroni.config import GlobalConfig
|
||||||
|
from patroni.dcs import Cluster, SyncState
|
||||||
from patroni.postgresql import Postgresql
|
from patroni.postgresql import Postgresql
|
||||||
|
|
||||||
from . import BaseTestPostgresql, psycopg_connect, mock_available_gucs
|
from . import BaseTestPostgresql, psycopg_connect, mock_available_gucs
|
||||||
@@ -24,14 +24,14 @@ class TestSync(BaseTestPostgresql):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestSync, self).setUp()
|
super(TestSync, self).setUp()
|
||||||
self.p.config.write_postgresql_conf()
|
self.p.config.write_postgresql_conf()
|
||||||
|
self.p._global_config = GlobalConfig({'synchronous_mode': True})
|
||||||
self.s = self.p.sync_handler
|
self.s = self.p.sync_handler
|
||||||
config = ClusterConfig(1, {'synchronous_mode': True}, 1)
|
|
||||||
self.cluster = Cluster(True, config, self.leader, Status.empty(), [self.me, self.other, self.leadermem],
|
|
||||||
None, SyncState(0, self.me.name, self.leadermem.name, 0), None, None, None)
|
|
||||||
global_config.update(self.cluster)
|
|
||||||
|
|
||||||
@patch.object(Postgresql, 'last_operation', Mock(return_value=1))
|
@patch.object(Postgresql, 'last_operation', Mock(return_value=1))
|
||||||
def test_pick_sync_standby(self):
|
def test_pick_sync_standby(self):
|
||||||
|
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None,
|
||||||
|
SyncState(0, self.me.name, self.leadermem.name), None, None, None)
|
||||||
|
|
||||||
pg_stat_replication = [
|
pg_stat_replication = [
|
||||||
{'pid': 100, 'application_name': self.leadermem.name, 'sync_state': 'sync', 'flush_lsn': 1},
|
{'pid': 100, 'application_name': self.leadermem.name, 'sync_state': 'sync', 'flush_lsn': 1},
|
||||||
{'pid': 101, 'application_name': self.me.name, 'sync_state': 'async', 'flush_lsn': 2},
|
{'pid': 101, 'application_name': self.me.name, 'sync_state': 'async', 'flush_lsn': 2},
|
||||||
@@ -40,8 +40,7 @@ class TestSync(BaseTestPostgresql):
|
|||||||
# sync node is a bit behind of async, but we prefer it anyway
|
# sync node is a bit behind of async, but we prefer it anyway
|
||||||
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=[self.leadermem.name,
|
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=[self.leadermem.name,
|
||||||
'on', pg_stat_replication]):
|
'on', pg_stat_replication]):
|
||||||
self.assertEqual(self.s.current_state(self.cluster), ('priority', 1, 1,
|
self.assertEqual(self.s.current_state(cluster), (CaseInsensitiveSet([self.leadermem.name]),
|
||||||
CaseInsensitiveSet([self.leadermem.name]),
|
|
||||||
CaseInsensitiveSet([self.leadermem.name])))
|
CaseInsensitiveSet([self.leadermem.name])))
|
||||||
|
|
||||||
# prefer node with sync_state='potential', even if it is slightly behind of async
|
# prefer node with sync_state='potential', even if it is slightly behind of async
|
||||||
@@ -49,46 +48,26 @@ class TestSync(BaseTestPostgresql):
|
|||||||
for r in pg_stat_replication:
|
for r in pg_stat_replication:
|
||||||
r['write_lsn'] = r.pop('flush_lsn')
|
r['write_lsn'] = r.pop('flush_lsn')
|
||||||
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_write', pg_stat_replication]):
|
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_write', pg_stat_replication]):
|
||||||
self.assertEqual(self.s.current_state(self.cluster), ('off', 0, 0, CaseInsensitiveSet(),
|
self.assertEqual(self.s.current_state(cluster), (CaseInsensitiveSet([self.leadermem.name]),
|
||||||
CaseInsensitiveSet([self.leadermem.name])))
|
CaseInsensitiveSet()))
|
||||||
|
|
||||||
# when there are no sync or potential candidates we pick async with the minimal replication lag
|
# when there are no sync or potential candidates we pick async with the minimal replication lag
|
||||||
for i, r in enumerate(pg_stat_replication):
|
for i, r in enumerate(pg_stat_replication):
|
||||||
r.update(replay_lsn=3 - i, application_name=r['application_name'].upper())
|
r.update(replay_lsn=3 - i, application_name=r['application_name'].upper())
|
||||||
missing = pg_stat_replication.pop(0)
|
missing = pg_stat_replication.pop(0)
|
||||||
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_apply', pg_stat_replication]):
|
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_apply', pg_stat_replication]):
|
||||||
self.assertEqual(self.s.current_state(self.cluster), ('off', 0, 0, CaseInsensitiveSet(),
|
self.assertEqual(self.s.current_state(cluster), (CaseInsensitiveSet([self.me.name]), CaseInsensitiveSet()))
|
||||||
CaseInsensitiveSet([self.me.name])))
|
|
||||||
|
|
||||||
# unknown sync node is ignored
|
# unknown sync node is ignored
|
||||||
missing.update(application_name='missing', sync_state='sync')
|
missing.update(application_name='missing', sync_state='sync')
|
||||||
pg_stat_replication.insert(0, missing)
|
pg_stat_replication.insert(0, missing)
|
||||||
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_apply', pg_stat_replication]):
|
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['', 'remote_apply', pg_stat_replication]):
|
||||||
self.assertEqual(self.s.current_state(self.cluster), ('off', 0, 0, CaseInsensitiveSet(),
|
self.assertEqual(self.s.current_state(cluster), (CaseInsensitiveSet([self.me.name]), CaseInsensitiveSet()))
|
||||||
CaseInsensitiveSet([self.me.name])))
|
|
||||||
|
|
||||||
# invalid synchronous_standby_names and empty pg_stat_replication
|
# invalid synchronous_standby_names and empty pg_stat_replication
|
||||||
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['a b', 'remote_apply', None]):
|
with patch.object(Postgresql, "_cluster_info_state_get", side_effect=['a b', 'remote_apply', None]):
|
||||||
self.p._major_version = 90400
|
self.p._major_version = 90400
|
||||||
self.assertEqual(self.s.current_state(self.cluster), ('off', 0, 0, CaseInsensitiveSet(),
|
self.assertEqual(self.s.current_state(cluster), (CaseInsensitiveSet(), CaseInsensitiveSet()))
|
||||||
CaseInsensitiveSet()))
|
|
||||||
|
|
||||||
@patch.object(Postgresql, 'last_operation', Mock(return_value=1))
|
|
||||||
def test_current_state_quorum(self):
|
|
||||||
self.cluster.config.data['synchronous_mode'] = 'quorum'
|
|
||||||
global_config.update(self.cluster)
|
|
||||||
|
|
||||||
pg_stat_replication = [
|
|
||||||
{'pid': 100, 'application_name': self.leadermem.name, 'sync_state': 'quorum', 'flush_lsn': 1},
|
|
||||||
{'pid': 101, 'application_name': self.other.name, 'sync_state': 'quorum', 'flush_lsn': 2}]
|
|
||||||
|
|
||||||
# sync node is a bit behind of async, but we prefer it anyway
|
|
||||||
with patch.object(Postgresql, "_cluster_info_state_get",
|
|
||||||
side_effect=['ANY 1 ({0},"{1}")'.format(self.leadermem.name, self.other.name),
|
|
||||||
'on', pg_stat_replication]):
|
|
||||||
self.assertEqual(self.s.current_state(self.cluster),
|
|
||||||
('quorum', 1, 2, CaseInsensitiveSet([self.other.name, self.leadermem.name]),
|
|
||||||
CaseInsensitiveSet([self.leadermem.name, self.other.name])))
|
|
||||||
|
|
||||||
def test_set_sync_standby(self):
|
def test_set_sync_standby(self):
|
||||||
def value_in_conf():
|
def value_in_conf():
|
||||||
@@ -107,7 +86,6 @@ class TestSync(BaseTestPostgresql):
|
|||||||
mock_reload.assert_not_called()
|
mock_reload.assert_not_called()
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'n1'")
|
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'n1'")
|
||||||
|
|
||||||
mock_reload.reset_mock()
|
|
||||||
self.s.set_synchronous_standby_names(CaseInsensitiveSet(['n1', 'n2']))
|
self.s.set_synchronous_standby_names(CaseInsensitiveSet(['n1', 'n2']))
|
||||||
mock_reload.assert_called()
|
mock_reload.assert_called()
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = '2 (n1,n2)'")
|
self.assertEqual(value_in_conf(), "synchronous_standby_names = '2 (n1,n2)'")
|
||||||
@@ -118,39 +96,7 @@ class TestSync(BaseTestPostgresql):
|
|||||||
self.assertEqual(value_in_conf(), None)
|
self.assertEqual(value_in_conf(), None)
|
||||||
|
|
||||||
mock_reload.reset_mock()
|
mock_reload.reset_mock()
|
||||||
|
self.p._global_config = GlobalConfig({'synchronous_mode': True})
|
||||||
self.s.set_synchronous_standby_names(CaseInsensitiveSet('*'))
|
self.s.set_synchronous_standby_names(CaseInsensitiveSet('*'))
|
||||||
mock_reload.assert_called()
|
mock_reload.assert_called()
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = '*'")
|
self.assertEqual(value_in_conf(), "synchronous_standby_names = '*'")
|
||||||
|
|
||||||
self.cluster.config.data['synchronous_mode'] = 'quorum'
|
|
||||||
global_config.update(self.cluster)
|
|
||||||
mock_reload.reset_mock()
|
|
||||||
self.s.set_synchronous_standby_names([], 1)
|
|
||||||
mock_reload.assert_called()
|
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'ANY 1 (*)'")
|
|
||||||
|
|
||||||
mock_reload.reset_mock()
|
|
||||||
self.s.set_synchronous_standby_names(['a', 'b'], 1)
|
|
||||||
mock_reload.assert_called()
|
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'ANY 1 (a,b)'")
|
|
||||||
|
|
||||||
mock_reload.reset_mock()
|
|
||||||
self.s.set_synchronous_standby_names(['a', 'b'], 3)
|
|
||||||
mock_reload.assert_called()
|
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = 'ANY 3 (a,b)'")
|
|
||||||
|
|
||||||
self.p._major_version = 90601
|
|
||||||
mock_reload.reset_mock()
|
|
||||||
self.s.set_synchronous_standby_names([], 1)
|
|
||||||
mock_reload.assert_called()
|
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = '1 (*)'")
|
|
||||||
|
|
||||||
mock_reload.reset_mock()
|
|
||||||
self.s.set_synchronous_standby_names(['a', 'b'], 1)
|
|
||||||
mock_reload.assert_called()
|
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = '1 (a,b)'")
|
|
||||||
|
|
||||||
mock_reload.reset_mock()
|
|
||||||
self.s.set_synchronous_standby_names(['a', 'b'], 3)
|
|
||||||
mock_reload.assert_called()
|
|
||||||
self.assertEqual(value_in_conf(), "synchronous_standby_names = '3 (a,b)'")
|
|
||||||
|
|||||||
+1
-43
@@ -13,21 +13,6 @@ available_dcs = [m.split(".")[-1] for m in dcs_modules()]
|
|||||||
config = {
|
config = {
|
||||||
"name": "string",
|
"name": "string",
|
||||||
"scope": "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": {
|
"restapi": {
|
||||||
"listen": "127.0.0.2:800",
|
"listen": "127.0.0.2:800",
|
||||||
"connect_address": "127.0.0.2:800",
|
"connect_address": "127.0.0.2:800",
|
||||||
@@ -103,8 +88,7 @@ config = {
|
|||||||
"nofailover": False,
|
"nofailover": False,
|
||||||
"clonefrom": False,
|
"clonefrom": False,
|
||||||
"noloadbalance": False,
|
"noloadbalance": False,
|
||||||
"nosync": False,
|
"nosync": False
|
||||||
"nostream": False
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,29 +357,3 @@ class TestValidator(unittest.TestCase):
|
|||||||
c["tags"]["failover_priority"] = -6
|
c["tags"]["failover_priority"] = -6
|
||||||
errors = schema(c)
|
errors = schema(c)
|
||||||
self.assertIn('tags.failover_priority -6 didn\'t pass validation: Wrong value', errors)
|
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))
|
|
||||||
|
|||||||
+11
-30
@@ -7,10 +7,8 @@ from kazoo.handlers.threading import SequentialThreadingHandler
|
|||||||
from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
|
from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
|
||||||
from kazoo.retry import RetryFailedError
|
from kazoo.retry import RetryFailedError
|
||||||
from mock import Mock, PropertyMock, patch
|
from mock import Mock, PropertyMock, patch
|
||||||
from patroni.dcs import get_dcs
|
|
||||||
from patroni.dcs.zookeeper import Cluster, PatroniKazooClient, \
|
from patroni.dcs.zookeeper import Cluster, PatroniKazooClient, \
|
||||||
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
|
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
|
||||||
from patroni.postgresql.mpp import get_mpp
|
|
||||||
|
|
||||||
|
|
||||||
class MockKazooClient(Mock):
|
class MockKazooClient(Mock):
|
||||||
@@ -150,9 +148,9 @@ class TestZooKeeper(unittest.TestCase):
|
|||||||
|
|
||||||
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
|
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.zk = get_dcs({'scope': 'test', 'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
|
self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test',
|
||||||
'zookeeper': {'hosts': ['localhost:2181'], 'set_acls': {'CN=principal2': ['ALL']}}})
|
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
|
||||||
self.assertIsInstance(self.zk, ZooKeeper)
|
'set_acls': {'CN=principal2': ['ALL']}})
|
||||||
|
|
||||||
def test_reload_config(self):
|
def test_reload_config(self):
|
||||||
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 10})
|
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 10})
|
||||||
@@ -166,47 +164,30 @@ class TestZooKeeper(unittest.TestCase):
|
|||||||
|
|
||||||
def test__cluster_loader(self):
|
def test__cluster_loader(self):
|
||||||
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
self.zk._base_path = self.zk._base_path.replace('test', 'bla')
|
||||||
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
|
self.zk._cluster_loader(self.zk.client_path(''))
|
||||||
self.zk._base_path = self.zk._base_path = '/broken'
|
self.zk._base_path = self.zk._base_path = '/broken'
|
||||||
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
|
self.zk._cluster_loader(self.zk.client_path(''))
|
||||||
self.zk._base_path = self.zk._base_path = '/legacy'
|
self.zk._base_path = self.zk._base_path = '/legacy'
|
||||||
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
|
self.zk._cluster_loader(self.zk.client_path(''))
|
||||||
self.zk._base_path = self.zk._base_path = '/no_node'
|
self.zk._base_path = self.zk._base_path = '/no_node'
|
||||||
self.zk._postgresql_cluster_loader(self.zk.client_path(''))
|
self.zk._cluster_loader(self.zk.client_path(''))
|
||||||
|
|
||||||
def test_get_cluster(self):
|
def test_get_cluster(self):
|
||||||
cluster = self.zk.get_cluster()
|
cluster = self.zk.get_cluster()
|
||||||
self.assertEqual(cluster.last_lsn, 500)
|
self.assertEqual(cluster.last_lsn, 500)
|
||||||
|
|
||||||
def test__get_citus_cluster(self):
|
def test__get_citus_cluster(self):
|
||||||
self.zk._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
self.zk._citus_group = '0'
|
||||||
for _ in range(0, 2):
|
for _ in range(0, 2):
|
||||||
cluster = self.zk.get_cluster()
|
cluster = self.zk.get_cluster()
|
||||||
self.assertIsInstance(cluster, Cluster)
|
self.assertIsInstance(cluster, Cluster)
|
||||||
self.assertIsInstance(cluster.workers[1], Cluster)
|
self.assertIsInstance(cluster.workers[1], Cluster)
|
||||||
|
|
||||||
@patch('patroni.dcs.logger.error')
|
@patch('patroni.dcs.zookeeper.logger.error')
|
||||||
def test_get_mpp_coordinator(self, mock_logger):
|
@patch.object(ZooKeeper, '_cluster_loader', Mock(side_effect=Exception))
|
||||||
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):
|
def test_get_citus_coordinator(self, mock_logger):
|
||||||
self.zk._mpp = get_mpp({'citus': {'group': 0, 'database': 'postgres'}})
|
self.assertIsNone(self.zk.get_citus_coordinator())
|
||||||
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()
|
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):
|
def test_delete_leader(self):
|
||||||
self.assertTrue(self.zk.delete_leader(self.zk.get_cluster().leader))
|
self.assertTrue(self.zk.delete_leader(self.zk.get_cluster().leader))
|
||||||
|
|||||||
Reference in New Issue
Block a user