Compare commits

..
Author SHA1 Message Date
Polina Bungina e013c1a6ee Reword confirmation message 2023-09-12 15:53:42 +02:00
Polina Bungina 03d9226633 Implement Failover.is_failover/switchover properties 2023-09-12 14:02:17 +02:00
Polina Bungina 5b4291bfab Refactor manual failover checks
- Implement the dedicated class that represents a manual failover
  request
- Move manual failover/switchover prechecks to the class method and use
  for both ctl and api
- Use a single parse_schedule function in both ctl and api
- Implement has_members_eligible_to_promote Ha method
- Fix get_members + role='any' exception msg
2023-09-12 13:13:17 +02:00
131 changed files with 3127 additions and 10309 deletions
+2 -2
View File
@@ -46,7 +46,7 @@ def install_packages(what):
packages = packages.get(what, [])
ver = versions.get(what)
if float(ver) >= 15:
packages += ['postgresql-{0}-citus-12.1'.format(ver)]
packages += ['postgresql-{0}-citus-11.2'.format(ver)]
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages)
@@ -110,7 +110,7 @@ def install_etcd():
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]
if platform == 'osx':
return subprocess.call(['brew', 'install', 'expect', 'postgresql@{0}'.format(version.split('.')[0])])
+1 -1
View File
@@ -1 +1 @@
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '14', 'kubernetes': '15'}
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
+1 -4
View File
@@ -24,11 +24,8 @@ jobs:
- name: Run tests and flake8
run: python .github/workflows/run_tests.py
- name: Install Python packaging build frontend
run: python -m pip install build
- name: Build a binary wheel and a source tarball
run: python -m build
run: python setup.py sdist bdist_wheel
- name: Publish distribution to Test PyPI
if: github.event_name == 'push'
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
unbuffer = ['timeout', '900', 'unbuffer']
else:
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])
unbuffer = ['unbuffer']
else:
+2 -3
View File
@@ -5,7 +5,6 @@ on:
push:
branches:
- master
- 'REL_[0-9]+_[0-9]+'
env:
CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
@@ -85,7 +84,7 @@ jobs:
env:
DCS: ${{ matrix.dcs }}
ETCDVERSION: 3.4.23
PGVERSION: 16.1-1 # for windows and macos
PGVERSION: 15.1-1 # for windows and macos
strategy:
fail-fast: false
matrix:
@@ -174,7 +173,7 @@ jobs:
- uses: jakebailey/pyright-action@v1
with:
version: 1.1.356
version: 1.1.320
docs:
runs-on: ubuntu-latest
+1 -2
View File
@@ -27,7 +27,7 @@ lib64
pip-log.txt
# Unit test / coverage reports
.coverage*
.coverage
.tox
nosetests.xml
coverage.xml
@@ -35,7 +35,6 @@ htmlcov
junit.xml
features/output*
dummy
result.json
# Translations
*.mo
+4 -7
View File
@@ -1,6 +1,6 @@
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
## It has all the necessary components to play/debug with a single node appliance, running etcd
ARG PG_MAJOR=16
ARG PG_MAJOR=15
ARG COMPRESS=false
ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data
@@ -94,9 +94,9 @@ RUN set -ex \
/usr/share/locale/??_?? \
/usr/share/postgresql/*/man \
/usr/share/postgresql-common/pg_wrapper \
/usr/share/vim/vim*/doc \
/usr/share/vim/vim*/lang \
/usr/share/vim/vim*/tutor \
/usr/share/vim/vim80/doc \
/usr/share/vim/vim80/lang \
/usr/share/vim/vim80/tutor \
# /var/lib/dpkg/info/* \
&& find /usr/bin -xtype l -delete \
&& 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 --install -s \
&& /bin/busybox sh -c "find $save_dirs -type d -depth -exec rmdir -p {} \; 2> /dev/null"; \
else \
/bin/busybox --install -s; \
fi
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 PGDATA=$PGDATA PATH=$PATH:$PGBIN
ENV ETCDCTL_API=3
COPY patroni /patroni/
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
+5 -6
View File
@@ -1,6 +1,6 @@
## This Dockerfile is meant to aid in the building and debugging patroni whilst developing on your local machine
## It has all the necessary components to play/debug with a single node appliance, running etcd
ARG PG_MAJOR=16
ARG PG_MAJOR=15
ARG COMPRESS=false
ARG PGHOME=/home/postgres
ARG PGDATA=$PGHOME/data
@@ -40,7 +40,7 @@ RUN set -ex \
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-12.1; \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3; \
fi \
\
# Cleanup all locales but en_US.UTF-8
@@ -113,9 +113,9 @@ RUN set -ex \
/usr/share/locale/??_?? \
/usr/share/postgresql/*/man \
/usr/share/postgresql-common/pg_wrapper \
/usr/share/vim/vim*/doc \
/usr/share/vim/vim*/lang \
/usr/share/vim/vim*/tutor \
/usr/share/vim/vim80/doc \
/usr/share/vim/vim80/lang \
/usr/share/vim/vim80/tutor \
# /var/lib/dpkg/info/* \
&& find /usr/bin -xtype l -delete \
&& 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 PGDATA=$PGDATA PATH=$PATH:$PGBIN
ENV ETCDCTL_API=3
COPY patroni /patroni/
COPY extras/confd/conf.d/haproxy.toml /etc/confd/conf.d/
+20 -13
View File
@@ -12,7 +12,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
Currently supported PostgreSQL versions: 9.3 to 16.
Currently supported PostgreSQL versions: 9.3 to 15.
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the `Citus support page <https://github.com/zalando/patroni/blob/master/docs/citus.rst>`__ in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
@@ -77,8 +77,23 @@ There are a few options available:
sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu
sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the list of dependencies when installing Patroni with pip (see below).
2. Install psycopg2 from the binary package
::
pip install psycopg2-binary
3. Install psycopg2 from source
::
pip install psycopg2>=2.5.4
4. Use psycopg 3.0 instead of psycopg2
::
pip install psycopg[binary]>=3.0.0
**General installation for pip**
@@ -104,20 +119,12 @@ raft
`pysyncobj` module in order to use python Raft implementation as DCS
aws
`boto3` in order to use AWS callbacks
all
all of the above (except psycopg family)
psycopg3
`psycopg[binary]>=3.0.0` module
psycopg2
`psycopg2>=2.5.4` module
psycopg2-binary
`psycopg2-binary` module
For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is:
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
::
pip install patroni[psycopg3,etcd3,aws]
pip install patroni[etcd,aws]
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed independently of Patroni.
@@ -151,7 +158,7 @@ run:
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
+5 -3
View File
@@ -19,6 +19,7 @@ services:
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
networks: [ demo ]
environment:
ETCDCTL_API: 3
ETCD_LISTEN_PEER_URLS: http://0.0.0.0:2380
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
@@ -27,19 +28,19 @@ services:
ETCD_UNSUPPORTED_ARCH: arm64
container_name: demo-etcd1
hostname: etcd1
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
etcd2:
<<: *etcd
container_name: demo-etcd2
hostname: etcd2
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
etcd3:
<<: *etcd
container_name: demo-etcd3
hostname: etcd3
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
haproxy:
image: ${PATRONI_TEST_IMAGE:-patroni-citus}
@@ -52,6 +53,7 @@ services:
- "5001:5001" # Load-balancing across workers primaries
command: haproxy
environment: &haproxy_env
ETCDCTL_API: 3
ETCDCTL_ENDPOINTS: http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
PATRONI_ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'"
PATRONI_SCOPE: demo
+3 -3
View File
@@ -25,19 +25,19 @@ services:
ETCD_UNSUPPORTED_ARCH: arm64
container_name: demo-etcd1
hostname: etcd1
command: etcd --name etcd1 --initial-advertise-peer-urls http://etcd1:2380
command: etcd -name etcd1 -initial-advertise-peer-urls http://etcd1:2380
etcd2:
<<: *etcd
container_name: demo-etcd2
hostname: etcd2
command: etcd --name etcd2 --initial-advertise-peer-urls http://etcd2:2380
command: etcd -name etcd2 -initial-advertise-peer-urls http://etcd2:2380
etcd3:
<<: *etcd
container_name: demo-etcd3
hostname: etcd3
command: etcd --name etcd3 --initial-advertise-peer-urls http://etcd3:2380
command: etcd -name etcd3 -initial-advertise-peer-urls http://etcd3:2380
haproxy:
image: ${PATRONI_TEST_IMAGE:-patroni}
+168 -168
View File
@@ -19,97 +19,102 @@ The haproxy listens on ports 5000 (connects to the primary) and 5001 (does load-
Example session:
$ docker compose up -d
✔ Network patroni_demo Created
✔ Container demo-etcd1 Started
✔ Container demo-haproxy Started
✔ Container demo-patroni1 Started
✔ Container demo-patroni2 Started
✔ Container demo-patroni3 Started
✔ Container demo-etcd2 Started
✔ Container demo-etcd3 Started
$ docker-compose up -d
Creating demo-haproxy ...
Creating demo-patroni2 ...
Creating demo-patroni1 ...
Creating demo-patroni3 ...
Creating demo-etcd2 ...
Creating demo-etcd1 ...
Creating demo-etcd3 ...
Creating demo-haproxy
Creating demo-patroni2
Creating demo-patroni1
Creating demo-patroni3
Creating demo-etcd1
Creating demo-etcd2
Creating demo-etcd2 ... done
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a37bcec56726 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd3
034ab73868a8 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni2
03837736f710 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni3
22815c3d85b3 patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd2
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
6375b0ba2d0a patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-patroni1
aef8bf3ee91f patroni "/bin/sh /entrypoint…" 15 minutes ago Up 15 minutes demo-etcd1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5b7a90b4cfbf patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd2
e30eea5222f2 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd1
83bcf3cb208f patroni "/bin/sh /entrypoint…" 29 seconds ago Up 27 seconds demo-etcd3
922532c56e7d patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni3
14f875e445f3 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni2
110d1073b383 patroni "/bin/sh /entrypoint…" 29 seconds ago Up 28 seconds demo-patroni1
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
2023-11-21 09:04:33,547 INFO: Selected new etcd server http://172.29.0.3:2379
2023-11-21 09:04:33,605 INFO: Lock owner: None; I am patroni1
2023-11-21 09:04:33,693 INFO: trying to bootstrap a new cluster
2019-02-20 08:19:32,714 INFO: Failed to import patroni.dcs.consul
2019-02-20 08:19:32,737 INFO: Selected new etcd server http://etcd3:2379
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
2023-11-21 09:04:34.921 UTC [43] LOG: listening on IPv4 address "0.0.0.0", port 5432
2023-11-21 09:04:34,922 INFO: postmaster pid=43
2023-11-21 09:04:34.922 UTC [43] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2023-11-21 09:04:34.925 UTC [47] LOG: database system was shut down at 2023-11-21 09:04:34 UTC
2023-11-21 09:04:34.928 UTC [43] LOG: database system is ready to accept connections
2019-02-20 08:19:39,310 INFO: postmaster pid=37
2019-02-20 08:19:39.314 UTC [37] LOG: listening on IPv4 address "0.0.0.0", port 5432
2019-02-20 08:19:39.321 UTC [37] 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
2019-02-20 08:19:39.354 UTC [40] FATAL: the database system is starting up
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
2023-11-21 09:04:34,938 INFO: establishing a new patroni heartbeat connection to postgres
2023-11-21 09:04:34,992 INFO: running post_bootstrap
2023-11-21 09:04:35,004 WARNING: User creation via "bootstrap.users" will be removed in v4.0.0
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'
2023-11-21 09:04:35,189 INFO: initialized a new cluster
2023-11-21 09:04:35,328 INFO: no action. I am (patroni1), the leader with the lock
2023-11-21 09:04:43,824 INFO: establishing a new patroni restapi connection to postgres
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
...
2019-02-20 08:19:39,383 INFO: establishing a new patroni connection to the postgres cluster
2019-02-20 08:19:39,408 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'"
2019-02-20 08:19:39,515 INFO: initialized a new cluster
2019-02-20 08:19:49,424 INFO: Lock owner: patroni1; I am patroni1
2019-02-20 08:19:49,447 INFO: Lock owner: patroni1; I am patroni1
2019-02-20 08:19:49,480 INFO: no action. i am the leader with the lock
2019-02-20 08:19:59,422 INFO: Lock owner: patroni1; I am patroni1
$ docker exec -ti demo-patroni1 bash
postgres@patroni1:~$ patronictl list
+ Cluster: demo (7303838734793224214) --------+----+-----------+
| Member | Host | Role | State | TL | Lag in MB |
+----------+------------+---------+-----------+----+-----------+
| patroni1 | 172.29.0.2 | Leader | running | 1 | |
| patroni2 | 172.29.0.6 | Replica | streaming | 1 | 0 |
| patroni3 | 172.29.0.5 | Replica | streaming | 1 | 0 |
+----------+------------+---------+-----------+----+-----------+
+---------+----------+------------+--------+---------+----+-----------+
| Cluster | Member | Host | Role | State | TL | Lag in MB |
+---------+----------+------------+--------+---------+----+-----------+
| demo | patroni1 | 172.22.0.3 | Leader | running | 1 | 0 |
| demo | patroni2 | 172.22.0.7 | | running | 1 | 0 |
| demo | patroni3 | 172.22.0.4 | | running | 1 | 0 |
+---------+----------+------------+--------+---------+----+-----------+
postgres@patroni1:~$ etcdctl get --keys-only --prefix /service/demo
postgres@patroni1:~$ etcdctl ls --recursive --sort -p /service/demo
/service/demo/config
/service/demo/initialize
/service/demo/leader
/service/demo/members/
/service/demo/members/patroni1
/service/demo/members/patroni2
/service/demo/members/patroni3
/service/demo/status
/service/demo/optime/
/service/demo/optime/leader
postgres@patroni1:~$ etcdctl member list
2bf3e2ceda5d5960, started, etcd2, http://etcd2:2380, http://172.29.0.3:2379
55b3264e129c7005, started, etcd3, http://etcd3:2380, http://172.29.0.7:2379
acce7233f8ec127e, started, etcd1, http://etcd1:2380, http://172.29.0.8:2379
1bab629f01fa9065: name=etcd3 peerURLs=http://etcd3:2380 clientURLs=http://etcd3:2379 isLeader=false
8ecb6af518d241cc: name=etcd2 peerURLs=http://etcd2:2380 clientURLs=http://etcd2:2379 isLeader=true
b2e169fcb8a34028: name=etcd1 peerURLs=http://etcd1:2380 clientURLs=http://etcd1:2379 isLeader=false
postgres@patroni1:~$ exit
$ docker exec -ti demo-haproxy bash
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -W
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.
postgres=# SELECT pg_is_in_recovery();
localhost/postgres=# select pg_is_in_recovery();
pg_is_in_recovery
───────────────────
f
(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
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.
postgres=# SELECT pg_is_in_recovery();
localhost/postgres=# select pg_is_in_recovery();
pg_is_in_recovery
───────────────────
t
@@ -122,86 +127,81 @@ The haproxy listens on ports 5000 (connects to the coordinator primary) and 5001
Example session:
$ docker compose -f docker-compose-citus.yml up -d
✔ Network patroni_demo Created
✔ Container demo-coord2 Started
✔ Container demo-work2-2 Started
✔ Container demo-etcd1 Started
✔ Container demo-haproxy Started
✔ Container demo-work1-1 Started
✔ Container demo-work2-1 Started
✔ Container demo-work1-2 Started
✔ Container demo-coord1 Started
✔ Container demo-etcd3 Started
✔ Container demo-coord3 Started
✔ Container demo-etcd2 Started
$ docker-compose -f docker-compose-citus.yml up -d
Creating demo-work2-1 ... done
Creating demo-work1-1 ... done
Creating demo-etcd2 ... done
Creating demo-etcd1 ... done
Creating demo-coord3 ... done
Creating demo-etcd3 ... done
Creating demo-coord1 ... done
Creating demo-haproxy ... done
Creating demo-work2-2 ... done
Creating demo-coord2 ... done
Creating demo-work1-2 ... done
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
79c95492fac9 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd3
77eb82d0f0c1 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-1
03dacd7267ef patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd1
db9206c66f85 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-etcd2
9a0fef7b7dd4 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-2
f06b031d99dc patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work2-2
f7c58545f314 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord2
383f9e7e188a patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-work1-1
f02e96dcc9d6 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord3
6945834b7056 patroni-citus "/bin/sh /entrypoint…" 11 minutes ago Up 11 minutes demo-coord1
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
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
852d8885a612 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-coord3
cdd692f947ab patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work1-2
9f4e340b36da patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-etcd3
d69c129a960a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd1
c5849689b8cd patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord1
c9d72bd6217d patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-1
24b1b43efa05 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-coord2
cb0cc2b4ca0a patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 3 seconds demo-work2-2
9796c6b8aad5 patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 5 seconds demo-work1-1
8baccd74dcae patroni-citus "/bin/sh /entrypoint…" 6 seconds ago Up 4 seconds demo-etcd2
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
2023-11-21 09:36:14,293 INFO: Selected new etcd server http://172.30.0.4:2379
2023-11-21 09:36:14,390 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,295 INFO: Selected new etcd server http://172.27.0.4:2379
2023-01-05 15:09:31,388 INFO: Lock owner: None; I am coord1
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
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-11-21 09:36:16.495 UTC [52] 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-11-21 09:36:16.498 UTC [56] LOG: database system was shut down at 2023-11-21 09:36:15 UTC
2023-11-21 09:36:16.501 UTC [52] LOG: database system is ready to accept connections
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-01-05 15:09:45.137 UTC [39] LOG: listening on IPv4 address "0.0.0.0", port 5432
2023-01-05 15:09:45.152 UTC [39] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2023-01-05 15:09:45.177 UTC [43] LOG: database system was shut down at 2023-01-05 15:09:32 UTC
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
2023-11-21 09:36:17,509 INFO: establishing a new patroni heartbeat connection to postgres
2023-11-21 09:36:17,569 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-11-21 09:36:17,783 INFO: establishing a new patroni restapi connection to postgres
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-11-21 09:36:17.969 UTC [70] LOG: starting maintenance daemon on database 16386 user 10
2023-11-21 09:36:17.969 UTC [70] CONTEXT: Citus maintenance daemon for database 16386 user 10
2023-11-21 09:36:18.159 UTC [54] LOG: checkpoint starting: immediate force wait
2023-11-21 09:36:18,162 INFO: initialized a new cluster
2023-11-21 09:36:18,164 INFO: Lock owner: coord1; I am coord1
2023-11-21 09:36:18,297 INFO: Enabled synchronous replication
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-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-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-11-21 09:36:18,299 DEBUG: query(SELECT nodeid, groupid, nodename, nodeport, noderole FROM pg_catalog.pg_dist_node WHERE noderole = 'primary', ())
2023-11-21 09:36:18,299 INFO: establishing a new patroni citus connection to postgres
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-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']
2023-01-05 15:09:46,139 INFO: establishing a new patroni connection to the postgres cluster
2023-01-05 15:09:46,208 INFO: running post_bootstrap
2023-01-05 15:09:47.209 UTC [55] LOG: starting maintenance daemon on database 16386 user 10
2023-01-05 15:09:47.209 UTC [55] CONTEXT: Citus maintenance daemon for database 16386 user 10
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-01-05 15:09:47.446 UTC [41] LOG: checkpoint starting: immediate force wait
2023-01-05 15:09:47,466 INFO: initialized a new cluster
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-01-05 15:09:47,594 INFO: establishing a new patroni connection to the postgres cluster
2023-01-05 15:09:47,467 INFO: Lock owner: coord1; I am coord1
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-01-05 15:09:47,924 INFO: no action. I am (coord1), the leader with the lock
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-01-05 15:09:51.283 UTC [41] LOG: checkpoint starting: immediate force wait
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-01-05 15:09:57,467 INFO: Lock owner: coord1; I am coord1
2023-01-05 15:09:57,569 INFO: Assigning synchronous standby status to ['coord3']
server signaled
2023-11-21 09:36:28.435 UTC [52] LOG: received SIGHUP, reloading configuration files
2023-11-21 09:36:28.436 UTC [52] 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-11-21 09:36:28.641 UTC [83] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1
2023-11-21 09:36:30,582 INFO: Synchronous standby status assigned to ['coord3']
2023-11-21 09:36:30,626 INFO: no action. I am (coord1), the leader with the lock
2023-11-21 09:36:38,250 INFO: no action. I am (coord1), the leader with the lock
...
2023-01-05 15:09:57.574 UTC [39] LOG: received SIGHUP, reloading configuration files
2023-01-05 15:09:57.580 UTC [39] LOG: parameter "synchronous_standby_names" changed to "coord3"
2023-01-05 15:09:59,637 INFO: Synchronous standby status assigned to ['coord3']
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-01-05 15:09:59.690 UTC [67] LOG: standby "coord3" is now a synchronous standby with priority 1
2023-01-05 15:09:59.690 UTC [67] STATEMENT: START_REPLICATION SLOT "coord3" 0/3000000 TIMELINE 1
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
postgres@haproxy:~$ etcdctl member list
2b28411e74c0c281, started, etcd3, http://etcd3:2380, http://172.30.0.4:2379
6c70137d27cfa6c1, started, etcd2, http://etcd2:2380, http://172.30.0.5:2379
a28f9a70ebf21304, started, etcd1, http://etcd1:2380, http://172.30.0.6:2379
1bab629f01fa9065, started, etcd3, http://etcd3:2380, http://172.27.0.10:2379
8ecb6af518d241cc, started, etcd2, http://etcd2:2380, http://172.27.0.4:2379
b2e169fcb8a34028, started, etcd1, http://etcd1:2380, http://172.27.0.7:2379
postgres@haproxy:~$ etcdctl get --keys-only --prefix /service/demo
/service/demo/0/config
@@ -229,7 +229,7 @@ Example session:
postgres@haproxy:~$ psql -h localhost -p 5000 -U postgres -d citus
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)
Type "help" for help.
@@ -240,67 +240,67 @@ Example session:
(1 row)
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
2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t
3 | 2 | 172.30.0.8 | 5432 | default | t | t | primary | default | t | t
1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t
3 | 2 | 172.27.0.8 | 5432 | default | t | t | primary | default | t | t
(3 rows)
citus=# \q
postgres@haproxy:~$ patronictl list
+ Citus cluster: demo ----------+--------------+-----------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+-----------+----+-----------+
| 0 | coord1 | 172.30.0.3 | Leader | running | 1 | |
| 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 |
| 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 |
| 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | |
| 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 |
| 2 | work2-1 | 172.30.0.8 | Leader | running | 1 | |
| 2 | work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 |
+-------+---------+-------------+--------------+-----------+----+-----------+
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+---------+----+-----------+
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | |
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 |
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 |
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | |
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 |
| 2 | work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 |
| 2 | work2-2 | 172.27.0.8 | Leader | running | 1 | |
+-------+---------+-------------+--------------+---------+----+-----------+
postgres@haproxy:~$ patronictl switchover --group 2 --force
Current cluster topology
+ Citus cluster: demo (group: 2, 7303846899271086103) --+-----------+
| Member | Host | Role | State | TL | Lag in MB |
+---------+-------------+--------------+-----------+----+-----------+
| work2-1 | 172.30.0.8 | Leader | running | 1 | |
| work2-2 | 172.30.0.11 | Sync Standby | streaming | 1 | 0 |
+---------+-------------+--------------+-----------+----+-----------+
2023-11-21 09:44:15.83849 Successfully switched over to "work2-2"
+ Citus cluster: demo (group: 2, 7303846899271086103) -------+
+ Citus cluster: demo (group: 2, 7185185529556963355) +-----------+
| Member | Host | Role | State | TL | Lag in MB |
+---------+-------------+--------------+---------+----+-----------+
| work2-1 | 172.27.0.11 | Sync Standby | running | 1 | 0 |
| work2-2 | 172.27.0.8 | Leader | running | 1 | |
+---------+-------------+--------------+---------+----+-----------+
2023-01-05 15:29:29.54204 Successfully switched over to "work2-1"
+ Citus cluster: demo (group: 2, 7185185529556963355) -------+
| Member | Host | Role | State | TL | Lag in MB |
+---------+-------------+---------+---------+----+-----------+
| work2-1 | 172.30.0.8 | Replica | stopped | | unknown |
| work2-2 | 172.30.0.11 | Leader | running | 1 | |
| work2-1 | 172.27.0.11 | Leader | running | 1 | |
| work2-2 | 172.27.0.8 | Replica | stopped | | unknown |
+---------+-------------+---------+---------+----+-----------+
postgres@haproxy:~$ patronictl list
+ Citus cluster: demo ----------+--------------+-----------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+-----------+----+-----------+
| 0 | coord1 | 172.30.0.3 | Leader | running | 1 | |
| 0 | coord2 | 172.30.0.12 | Replica | streaming | 1 | 0 |
| 0 | coord3 | 172.30.0.2 | Sync Standby | streaming | 1 | 0 |
| 1 | work1-1 | 172.30.0.7 | Leader | running | 1 | |
| 1 | work1-2 | 172.30.0.10 | Sync Standby | streaming | 1 | 0 |
| 2 | work2-1 | 172.30.0.8 | Sync Standby | streaming | 2 | 0 |
| 2 | work2-2 | 172.30.0.11 | Leader | running | 2 | |
+-------+---------+-------------+--------------+-----------+----+-----------+
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
| Group | Member | Host | Role | State | TL | Lag in MB |
+-------+---------+-------------+--------------+---------+----+-----------+
| 0 | coord1 | 172.27.0.6 | Leader | running | 1 | |
| 0 | coord2 | 172.27.0.5 | Replica | running | 1 | 0 |
| 0 | coord3 | 172.27.0.9 | Sync Standby | running | 1 | 0 |
| 1 | work1-1 | 172.27.0.2 | Leader | running | 1 | |
| 1 | work1-2 | 172.27.0.12 | Sync Standby | running | 1 | 0 |
| 2 | work2-1 | 172.27.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
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)
Type "help" for help.
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
3 | 2 | 172.30.0.11 | 5432 | default | t | t | primary | default | t | t
2 | 1 | 172.30.0.7 | 5432 | default | t | t | primary | default | t | t
1 | 0 | 172.27.0.6 | 5432 | default | t | t | primary | default | t | f
3 | 2 | 172.27.0.11 | 5432 | default | t | t | primary | default | t | t
2 | 1 | 172.27.0.2 | 5432 | default | t | t | primary | default | t | t
(3 rows)
+1 -3
View File
@@ -13,8 +13,6 @@ readonly PATRONI_NAMESPACE="${PATRONI_NAMESPACE%/}"
DOCKER_IP=$(hostname --ip-address)
readonly DOCKER_IP
export DUMB_INIT_SETSID=0
case "$1" in
haproxy)
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_SSLROOTCERT="${PATRONI_SUPERUSER_SSLROOTCERT:-$PGSSLROOTCERT}"
exec dumb-init python3 /patroni.py postgres0.yml
exec python3 /patroni.py postgres0.yml
+13 -13
View File
@@ -14,24 +14,25 @@ Global/Universal
Log
---
- **PATRONI\_LOG\_TYPE**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
- **PATRONI\_LOG\_LEVEL**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **PATRONI\_LOG\_TRACEBACK\_LEVEL**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **PATRONI\_LOG\_LEVEL=DEBUG**.
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. If the log type is **plain**, the log format should be a string.
Refer to `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
Default value is **%(asctime)s %(levelname)s: %(message)s**
- **PATRONI\_LOG\_FORMAT**: sets the log formatting string. Default value is **%(asctime)s %(levelname)s: %(message)s** (see `the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_)
- **PATRONI\_LOG\_DATEFORMAT**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **PATRONI\_LOG\_STATIC\_FIELDS**: add additional fields to the log. This option is only available when the log type is set to **json**. Example ``PATRONI_LOG_STATIC_FIELDS="{app: patroni}"``
- **PATRONI\_LOG\_MAX\_QUEUE\_SIZE**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **PATRONI\_LOG\_DIR**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this env variable, the application will retain 4 25MB logs by default. You can tune those retention values with `PATRONI_LOG_FILE_NUM` and `PATRONI_LOG_FILE_SIZE` (see below).
- **PATRONI\_LOG\_FILE\_NUM**: The number of application logs to retain.
- **PATRONI\_LOG\_FILE\_SIZE**: Size of patroni.log file (in bytes) that triggers a log rolling.
- **PATRONI\_LOG\_LOGGERS**: Redefine logging level per python module. Example ``PATRONI_LOG_LOGGERS="{patroni.postmaster: WARNING, urllib3: DEBUG}"``
Bootstrap configuration
-----------------------
It is possible to create new database users right after the successful initialization of a new cluster. This process is defined by the following variables:
- **PATRONI\_<username>\_PASSWORD='<password>'**
- **PATRONI\_<username>\_OPTIONS='list,of,options'**
Example: defining ``PATRONI_admin_PASSWORD=strongpasswd`` and ``PATRONI_admin_OPTIONS='createrole,createdb'`` will cause creation of the user **admin** with the password **strongpasswd** that is allowed to create other users and databases.
Citus
-----
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
@@ -93,7 +94,6 @@ ZooKeeper
- **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password.
- **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\_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::
It is required to install ``kazoo>=2.6.0`` to support SSL.
@@ -209,10 +209,10 @@ REST API
CTL
---
- **PATRONICTL\_CONFIG\_FILE**: (optional) location of the configuration file.
- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter.
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
- **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs.
- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter.
- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **PATRONI\_CTL\_CERTFILE**: (optional) Specifies the file with the client certificate in the PEM format.
- **PATRONI\_CTL\_KEYFILE**: (optional) Specifies the file with the client secret key in the PEM format.
- **PATRONI\_CTL\_KEYFILE\_PASSWORD**: (optional) Specifies a password for decrypting the client keyfile.
+12 -16
View File
@@ -38,18 +38,14 @@ After that you just need to start Patroni and it will handle the rest:
2. If ``max_prepared_transactions`` isn't explicitly set in the global
:ref:`dynamic configuration <dynamic_configuration>` Patroni will
automatically set it to ``2*max_connections``.
3. The ``citus.local_hostname`` GUC value will be adjusted from ``localhost`` to the
value that Patroni is using in order to connect to the local PostgreSQL
instance. The value sometimes should be different from the ``localhost``
because PostgreSQL might be not listening on it.
4. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``.
5. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo``
3. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``.
4. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo``
table to allow cross-node communication. Don't forget to update them if
later you decide to change superuser username/password/sslcert/sslkey!
6. The coordinator primary node will automatically discover worker primary
5. The coordinator primary node will automatically discover worker primary
nodes and add them to the ``pg_dist_node`` table using the
``citus_add_node()`` function.
7. Patroni will also maintain ``pg_dist_node`` in case failover/switchover
6. Patroni will also maintain ``pg_dist_node`` in case failover/switchover
on the coordinator or worker clusters occurs.
patronictl
@@ -61,7 +57,7 @@ clusters that are just logically groupped together using the
PostgreSQL. Therefore in most cases it is not possible to manage them as a
single entity.
It results in two major differences in :ref:`patronictl` behaviour when
It results in two major differences in ``patronictl`` behaviour when
``patroni.yaml`` has the ``citus`` section comparing with the usual:
1. The ``list`` and the ``topology`` by default output all members of the Citus
@@ -69,12 +65,12 @@ It results in two major differences in :ref:`patronictl` behaviour when
which Citus group they belong to.
2. For all ``patronictl`` commands the new option is introduced, named
``--group``. For some commands the default value for the group might be
taken from the ``patroni.yaml``. For example, :ref:`patronictl_pause` will
taken from the ``patroni.yaml``. For example, ``patronictl pause`` will
enable the maintenance mode by default for the ``group`` that is set in the
``citus`` section, but for example for :ref:`patronictl_switchover` or
:ref:`patronictl_remove` the group must be explicitly specified.
``citus`` section, but for example for ``patronictl switchover`` or
``patronictl remove`` the group must be explicitly specified.
An example of :ref:`patronictl_list` output for the Citus cluster::
An example of ``patronictl list`` output for the Citus cluster::
postgres@coord1:~$ patronictl list demo
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
@@ -119,7 +115,7 @@ the coordinator for the shards hosted on a worker node. The switchover then
happens while the traffic is kept on the coordinator, and resumes as soon as a
new primary worker node is ready to accept read-write queries.
An example of :ref:`patronictl_switchover` on the worker cluster::
An example of ``patronictl switchover`` on the worker cluster::
postgres@coord1:~$ patronictl switchover demo
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
@@ -144,7 +140,7 @@ An example of :ref:`patronictl_switchover` on the worker cluster::
| work2-1 | 172.27.0.5 | Sync Standby | running | 1 | 0 |
| 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 perform a switchover in the cluster demo, demoting current primary work2-2? [y/N]: y
2022-12-22 07:02:40.33003 Successfully switched over to "work2-1"
+ Citus cluster: demo (group: 2, 7179854924063375386) ------+
| Member | Host | Role | State | TL | Lag in MB |
@@ -347,7 +343,7 @@ Citus upgrades and PostgreSQL major upgrades
First, please read about upgrading Citus version in the `documentation`__.
There is one minor change in the process. When executing upgrade, you have to
use :ref:`patronictl_restart` instead of ``systemctl restart`` to restart
use ``patronictl restart`` instead of ``systemctl restart`` to restart
PostgreSQL.
__ https://docs.citusdata.com/en/latest/admin_guide/upgrading_citus.html
+1 -1
View File
@@ -112,10 +112,10 @@ todo_include_todos = True
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
if not on_rtd: # only import and set the theme if we're building docs locally
import sphinx_rtd_theme
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# Theme options are theme-specific and customize the look and feel of a theme
+17 -25
View File
@@ -3,29 +3,21 @@
Contributing guidelines
=======================
.. _chatting:
Wanna contribute to Patroni? Yay - here is how!
Chatting
--------
If you have a question, looking for an interactive troubleshooting help or want to chat with other Patroni users, join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
.. _reporting_bugs:
Reporting bugs
--------------
Before reporting a bug please make sure to **reproduce it with the latest Patroni version**!
Also please double check if the issue already exists in our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
Running tests
-------------
Requirements for running behave tests:
#. PostgreSQL packages including `contrib <https://www.postgresql.org/docs/current/contrib.html>`__ modules need to be installed.
#. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
#. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
1. PostgreSQL packages need to be installed.
2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
Install dependencies:
@@ -47,13 +39,6 @@ After you have all dependencies installed, you can run the various test suites:
# Run the pytest suite in tests/:
python setup.py test
# Moreover, you may want to run tests in different scopes for debugging purposes,
# the -s option include print output during test execution.
# Tests in pytest typically follow the pattern: FILEPATH::CLASSNAME::TESTNAME.
pytest -s tests/test_api.py
pytest -s tests/test_api.py::TestRestApiHandler
pytest -s tests/test_api.py::TestRestApiHandler::test_do_GET
# Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/;
# modify DCS as desired (raft has no dependencies so is the easiest to start with):
DCS=raft python -m behave
@@ -158,12 +143,12 @@ If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op
Behave tests
^^^^^^^^^^^^
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 16 and then run all
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 15 and then run all
behave tests. This can take quite a long time to run so you might want to limit the scope to a select version of
Postgres or to a specific feature set or steps.
To specify the version of postgres include the full name of the dependent image build env that you want and then the
behave env name. For instance if you want Postgres 14 use:
behave env name. For instance if you want Postgres 15 use:
.. code-block:: bash
@@ -178,12 +163,19 @@ the watchdog behave feature test scenario with all versions of Postgres.
Of course you can combine the two.
Reporting issues
----------------
If you have a question about patroni or have a problem using it, please read the :ref:`README <readme>` before filing an issue.
Also double check with the current issues on our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
Contributing a pull request
---------------------------
#. Fork the repository, develop and test your code changes.
#. Reflect changes in the user documentation.
#. Submit a pull request with a clear description of the changes objective. Link an existing issue if necessary.
1) Submit a comment to the relevant issue or create a new issue describing your proposed change.
2) Do a fork, develop and test your code changes.
3) Include documentation
4) Submit a pull request.
You'll get feedback about your pull request as soon as possible.
+1 -1
View File
@@ -60,4 +60,4 @@ F.A.Q.
- How to enable the Failsafe Mode?
Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API <rest_api>` or :ref:`patronictl edit-config -s failsafe_mode=true <patronictl_edit_config_parameters>`
Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API <rest_api>` or ``patronictl edit-config -s failsafe_mode=true``
+7 -38
View File
@@ -6,20 +6,11 @@ Dynamic Configuration Settings
Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
In order to change the dynamic configuration you can use either :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10, minimum possible value: 1
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30, minimum possible value: 20
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10, minimum possible value: 3
.. warning::
when changing values of **loop_wait**, **retry_timeout**, or **ttl** you have to follow the rule:
.. code-block:: python
loop_wait + 2 * retry_timeout <= ttl
In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
- **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.
@@ -55,9 +46,9 @@ In order to change the dynamic configuration you can use either :ref:`patronictl
- **archive\_cleanup\_command**: cleanup command for standby leader
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. With PostgreSQL 11 onwards permanent physical slots are created on all nodes and their position is advanced every **loop_wait** seconds. For PostgreSQL versions older than 11 permanent physical replication slots are maintained only on the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent replication slots requires **postgresql.use_slots** to be set to ``true``. If there are permanent logical replication slots defined Patroni will automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. The physical slots are maintained only in the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current node it will not be created on this node. If you add a permanent physical replication slot which name matches the name of a Patroni member, Patroni will ensure that the slot that was created is not removed even if the corresponding member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when you want replication slots used by members to persist during temporary failures or when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS, when the slot is no longer required, due to its effect on normal functioning of Patroni.
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current leader it will not be created. Please note that Patroni does not make checks for permanent slot names added to this configuration matching those that Patroni creates automatically for members. If those names are added, Patroni will ensure that any slots that were created are not removed even if the member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS due to its effect on normal functioning of Patroni.
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
- **database**: the database name where logical slots should be created.
@@ -89,26 +80,4 @@ Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
plugin: test_decoding
- name: ignored_physical_slot_name
type: physical
...
Note: if cluster topology is static (fixed number of nodes that never change their names) you can configure permanent physical replication slots with names corresponding to names of nodes to avoid recycling of WAL files while replica is temporary down:
.. code:: YAML
slots:
node_name1:
type: physical
node_name2:
type: physical
node_name3:
type: physical
...
.. 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.
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.
...
+4 -4
View File
@@ -36,18 +36,18 @@ You can find below an overview of steps for converting an existing Postgres clus
#. If you are running Postgres through systemd, then disable the Postgres systemd unit. This is performed as Patroni manages starting and stopping the Postgres daemon.
#. Create a YAML configuration file for Patroni. You can use :ref:`Patroni configuration generation and validation tooling <validate_generate_config>` for that.
#. Create a YAML configuration file for Patroni.
* **Note (specific for the primary node):** If you have replication slots being used for replication between cluster members, then it is recommended that you enable ``use_slots`` and configure the existing replication slots as permanent via the ``slots`` configuration item. Be aware that Patroni automatically creates replication slots for replication between members, and drops replication slots that it does not recognize, when ``use_slots`` is enabled. The idea of using permanent slots here is to allow your existing slots to persist while the migration to Patroni is in progress. See :ref:`YAML Configuration Settings <yaml_configuration>` for details.
#. Start Patroni using the ``patroni`` systemd service unit. It automatically detects that Postgres is already running and starts monitoring the instance.
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through :ref:`patronictl restart cluster-name member-name <patronictl_restart_parameters>` command. For minimal downtime you might want to split this step into:
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through ``patronictl restart cluster-name member-name`` command. For minimal downtime you might want to split this step into:
#. Immediate restart of the standby nodes.
#. Scheduled restart of the primary node within a maintenance window.
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through :ref:`patronictl edit-config cluster-name member-name <patronictl_edit_config_parameters>` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through ``patronictl edit-config cluster-name member-name`` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
.. code-block:: sql
@@ -73,7 +73,7 @@ The only possible way to do a major upgrade currently is:
#. Stop Patroni
#. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
#. Update patroni.yml
#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running :ref:`patronictl remove cluster-name <patronictl_remove_parameters>` . It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
#. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
#. Start Patroni on the primary node.
#. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
-329
View File
@@ -1,329 +0,0 @@
.. _faq:
FAQ
===
In this section you will find answers for the most frequently asked questions about Patroni.
Each sub-section attempts to focus on different kinds of questions.
We hope that this helps you to clarify most of your questions.
If you still have further concerns or find yourself facing an unexpected issue, please refer to :ref:`chatting` and :ref:`reporting_bugs` for instructions on how to get help or report issues.
Comparison with other HA solutions
----------------------------------
Why does Patroni require a separate cluster of DCS nodes while other solutions like ``repmgr`` do not?
There are different ways of implementing HA solutions, each of them with their pros and cons.
Software like ``repmgr`` performs communication among the nodes to decide when actions should be taken.
Patroni on the other hand relies on the state stored in the DCS. The DCS acts as a source of truth for Patroni to decide what it should do.
While having a separate DCS cluster can make you bloat your architecture, this approach also makes it less likely for split-brain scenarios to happen in your Postgres cluster.
What is the difference between Patroni and other HA solutions in regards to Postgres management?
Patroni does not just manage the high availability of the Postgres cluster but also manages Postgres itself.
If Postgres nodes do not exist yet, it takes care of bootstrapping the primary and the standby nodes, and also manages Postgres configuration of the nodes. If the Postgres nodes already exist, Patroni will take over management of the cluster.
Besides the above, Patroni also has self-healing capabilities. In other words, if a primary node fails, Patroni will not only fail over to a replica, but also attempt to rejoin the former primary as a replica of the new primary. Similarly, if a replica fails, Patroni will attempt to rejoin that replica.
That is way we call Patroni as a "template for HA solutions". It goes further than just managing physical replication: it manages Postgres as a whole.
DCS
---
Can I use the same ``etcd`` cluster to store data from two or more Patroni clusters?
Yes, you can!
Information about a Patroni cluster is stored in the DCS under a path prefixed with the ``namespace`` and ``scope`` Patroni settings.
As long as you do not have conflicting namespace and scope across different Patroni clusters, you should be able to use the same DCS cluster to store information from multiple Patroni clusters.
What occurs if I attempt to use the same combination of ``namespace`` and ``scope`` for different Patroni clusters that point to the same DCS cluster?
The second Patroni cluster that attempts to use the same ``namespace`` and ``scope`` will not be able to manage Postgres because it will find information related with that same combination in the DCS, but with an incompatible Postgres system identifier.
The mismatch on the system identifier causes Patroni to abort the management of the second cluster, as it assumes that refers to a different cluster and that the user has misconfigured Patroni.
Make sure to use different ``namespace`` / ``scope`` when dealing with different Patroni clusters that share the same DCS cluster.
What occurs if I lose my DCS cluster?
The DCS is used to store basically status and the dynamic configuration of the Patroni cluster.
They very first consequence is that all the Patroni clusters that rely on that DCS will go to read-only mode -- unless :ref:`dcs_failsafe_mode` is enabled.
What should I do if I lose my DCS cluster?
There are three possible outcomes upon losing your DCS cluster:
1. The DCS cluster is fully recovered: this requires no action from the Patroni side. Once the DCS cluster is recovered, Patroni should be able to recover too;
2. The DCS cluster is re-created in place, and the endpoints remain the same. No changes are required on the Patroni side;
3. A new DCS cluster is created with different endpoints. You will need to update the DCS endpoints in the Patroni configuration of each Patroni node.
If you face scenario ``2.`` or ``3.`` Patroni will take care of creating the status information again based on the current status of the cluster, and recreate the dynamic configuration on the DCS based on a backup file named ``patroni.dynamic.json`` which is stored inside the Postgres data directory of each member of the Patroni cluster.
What occurs if I lose majority in my DCS cluster?
The DCS will become unresponsive, which will cause Patroni to demote the current read/write Postgres node.
Remember: Patroni relies on the state of the DCS to take actions on the cluster.
You can use the :ref:`dcs_failsafe_mode` to alleviate that situation.
patronictl
----------
Do I need to run :ref:`patronictl` in the Patroni host?
No, you do not need to do that.
Running :ref:`patronictl` in the Patroni host is handy if you have access to the Patroni host because you can use the very same configuration file from the ``patroni`` agent for the :ref:`patronictl` application.
However, :ref:`patronictl` is basically a client and it can be executed from remote machines. You just need to provide it with enough configuration so it can reach the DCS and the REST API of the Patroni member(s).
Why did the information from one of my Patroni members disappear from the output of :ref:`patronictl_list` command?
Information shown by :ref:`patronictl_list` is based on the contents of the DCS.
If information about a member disappeared from the DCS it is very likely that the Patroni agent on that node is not running anymore, or it is not able to communicate with the DCS.
As the member is not able to update the information, the information eventually expires from the DCS, and consequently the member is not shown anymore in the output of :ref:`patronictl_list`.
Why is the information about one of my Patroni members not up-to-date in the output of :ref:`patronictl_list` command?
Information shown by :ref:`patronictl_list` is based on the contents of the DCS.
By default, that information is updated by Patroni roughly every ``loop_wait`` seconds.
In other words, even if everything is normally functional you may still see a "delay" of up to ``loop_wait`` seconds in the information stored in the DCS.
Be aware that that is not a rule, though. Some operations performed by Patroni cause it to immediately update the DCS information.
Configuration
-------------
What is the difference between dynamic configuration and local configuration?
Dynamic configuration (or global configuration) is the configuration stored in the DCS, and which is applied to all members of the Patroni cluster.
This is primarily where you should store your configuration.
Settings that are specific to a node, or settings that you would like to overwrite the global configuration with, you should set only on the desired Patroni member as a local configuration.
That local configuration can be specified either through the configuration file or through environment variables.
See more in :ref:`patroni_configuration`.
What are the types of configuration in Patroni, and what is the precedence?
The types are:
* Dynamic configuration: applied to all members;
* Local configuration: applied to the local member, overrides dynamic configuration;
* Environment configuration: applied to the local member, overrides both dynamic and local configuration.
**Note:** some Postgres GUCs can only be set globally, i.e., through dynamic configuration. Besides that, there are GUCs which Patroni enforces a hard-coded value.
See more in :ref:`patroni_configuration`.
Is there any facility to help me create my Patroni configuration file?
Yes, there is.
You can use ``patroni --generate-sample-config`` or ``patroni --generate-config`` commands to generate a sample Patroni configuration or a Patroni configuration based on an existing Postgres instance, respectively.
Please refer to :ref:`generate_sample_config` and :ref:`generate_config` for more details.
I changed my parameters under ``bootstrap.dcs`` configuration but Patroni is not applying the changes to the cluster members. What is wrong?
The values configured under ``bootstrap.dcs`` are only used when bootstrapping a fresh cluster. Those values will be written to the DCS during the bootstrap.
After the bootstrap phase finishes, you will only be able to change the dynamic configuration through the DCS.
Refer to the next question for more details.
How can I change my dynamic configuration?
You need to change the configuration in the DCS. That is accomplished either through:
* :ref:`patronictl_edit_config`; or
* A ``PATCH`` request to :ref:`config_endpoint`.
How can I change my local configuration?
You need to change the configuration file of the corresponding Patroni member and signal the Patroni agent with ``SIHGUP``. You can do that using either of these approaches:
* Send a ``POST`` request to the REST API :ref:`reload_endpoint`; or
* Run :ref:`patronictl_reload`; or
* Locally signal the Patroni process with ``SIGHUP``:
* If you started Patroni through systemd, you can use the command ``systemctl reload PATRONI_UNIT.service``, ``PATRONI_UNIT`` being the name of the Patroni service; or
* If you started Patroni through other means, you will need to identify the ``patroni`` process and run ``kill -s HUP PID``, ``PID`` being the process ID of the ``patroni`` process.
**Note:** there are cases where a reload through the :ref:`patronictl_reload` may not work:
* Expired REST API certificates: you can mitigate that by using the ``-k`` option of the :ref:`patronictl`;
* Wrong credentials: for example when changing ``restapi`` or ``ctl`` credentials in the configuration file, and using that same configuration file for Patroni and :ref:`patronictl`.
How can I change my environment configuration?
The environment configuration is only read by Patroni during startup.
With that in mind, if you change the environment configuration you will need to restart the corresponding Patroni agent.
Take care to not cause a failover in the cluster! You might be interested in checking :ref:`patronictl_pause`.
What occurs if I change a Postgres GUC that requires a reload?
When you change the dynamic or the local configuration as explained in the previous questions, Patroni will take care of reloading the Postgres configuration for you.
What occurs if I change a Postgres GUC that requires a restart?
Patroni will mark the affected members with a flag of ``pending restart``.
It is up to you to determine when and how to restart the members. That can be accomplished either through:
* :ref:`patronictl_restart`; or
* A ``POST`` request to :ref:`restart_endpoint`.
**Note:** some Postgres GUCs require a special management in terms of the order for restarting the Postgres nodes. Refer to :ref:`shared_memory_gucs` for more details.
What is the difference between ``etcd`` and ``etcd3`` in Patroni configuration?
``etcd`` uses the API version 2 of ``etcd``, while ``etcd3`` uses the API version 3 of ``etcd``.
Be aware that information stored by the API version 2 is not manageable by API version 3 and vice-versa.
We recommend that you configure ``etcd3`` instead of ``etcd`` because:
* API version 2 is disabled by default from Etcd v3.4 onward;
* API version 2 will be completely removed on Etcd v3.6.
I have ``use_slots`` enabled in my Patroni configuration, but when a cluster member goes offline for some time, the replication slot used by that member is dropped on the upstream node. What can I do to avoid that issue?
You can configure a permanent physical replication slot for the members.
Since Patroni ``3.2.0`` it is now possible to have member slots as permanent slots managed by Patroni.
Patroni will create the permanent physical slots on all nodes, and make sure to not remove the slots, as well as to advance the slots' LSN on all nodes according to the LSN that has been consumed by the member.
Later, if you decide to remove the corresponding member, it's **your responsability** to adjust the permanent slots configuration, otherwise Patroni will keep the slots around forever.
**Note:** on Patroni older than ``3.2.0`` you could still have member slots configured as permanent physical slots, however they would be managed only on the current leader. That is, in case of failover/switchover these slots would be created on the new leader, but that wouldn't guarantee that it had all WAL segments for the absent node.
**Note:** even with Patroni ``3.2.0`` there might be a small race condition. In the very beginning, when the slot is created on the replica it could be ahead of the same slot on the leader and in case if nobody is consuming the slot there is still a chance that some files could be missing after failover. With that in mind, it is recommended that you configure continuous archiving, which makes it possible to restore required WALs or perform PITR.
What is the difference between ``loop_wait``, ``retry_timeout`` and ``ttl``?
Patroni performs what we call a HA cycle from time to time. On each HA cycle it takes care of performing a series of checks on the cluster to determine its healthiness, and depending on the status it may take actions, like failing over to a standby.
``loop_wait`` determines for how long, in seconds, Patroni should sleep before performing a new cycle of HA checks.
``retry_timeout`` sets the timeout for retry operations on the DCS and on Postgres. For example: if the DCS is unresponsive for more than ``retry_timeout`` seconds, Patroni might demote the primary node as a security action.
``ttl`` sets the lease time on the ``leader`` lock in the DCS. If the current leader of the cluster is not able to renew the lease during its HA cycles for longer than ``ttl``, then the lease will expire and that will trigger a ``leader race`` in the cluster.
**Note:** when modifying these settings, please keep in mind that Patroni enforces the rule and minimal values described in :ref:`dynamic_configuration` section of the docs.
Postgres management
-------------------
Can I change Postgres GUCs directly in Postgres configuration?
You can, but you should avoid that.
Postgres configuration is managed by Patroni, and attempts to edit the configuration files may end up being frustrated by Patroni as it may eventually overwrite them.
There are a few options available to overcome the management performed by Patroni:
* Change Postgres GUCs through ``$PGDATA/postgresql.base.conf``; or
* Define a ``postgresql.custom_conf`` which will be used instead of ``postgresql.base.conf`` so you can manage that externally; or
* Change GUCs using ``ALTER SYSTEM`` / ``ALTER DATABASE`` / ``ALTER USER``.
You can find more information about that in the section :ref:`important_configuration_rules`.
In any case we recommend that you manage all the Postgres configuration through Patroni. That will centralize the management and make it easier to debug Patroni when needed.
Can I restart Postgres nodes directly?
No, you should **not** attempt to manage Postgres directly!
Any attempt of bouncing the Postgres server without Patroni can lead your cluster to face failovers.
If you need to manage the Postgres server, do that through the ways exposed by Patroni.
Is Patroni able to take over management of an already existing Postgres cluster?
Yes, it can!
Please refer to :ref:`existing_data` for detailed instructions.
How does Patroni manage Postgres?
Patroni takes care of bringing Postgres up and down by running the Postgres binaries, like ``pg_ctl`` and ``postgres``.
With that in mind you **MUST** disable any other sources that could manage the Postgres clusters, like the systemd units, e.g. ``postgresql.service``. Only Patroni should be able to start, stop and promote Postgres instances in the cluster. Not doing so may result in split-brain scenarios. For example: if the node running as a primary failed and the unit ``postgresql.service`` is enabled, it may bring Postgres back up and cause a split-brain.
Concepts and requirements
-------------------------
Which are the applications that make part of Patroni?
Patroni basically ships a couple applications:
* ``patroni``: This is the Patroni agent, which takes care of managing a Postgres node;
* ``patronictl``: This is a command-line utility used to interact with a Patroni cluster (perform switchovers, restarts, changes in the configuration, etc.). Please find more information in :ref:`patronictl`.
What is a ``standby cluster`` in Patroni?
It is a cluster that does not have any primary Postgres node running, i.e., there is no read/write member in the cluster.
These kinds of clusters exist to replicate data from another cluster and are usually useful when you want to replicate data across data centers.
There will be a leader in the cluster which will be a standby in charge of replicating changes from a remote Postgres node.
Then, there will be a set of standbys configured with cascading replication from such leader member.
**Note:** the standby cluster doesn't know anything about the source cluster which it is replicating from -- it can even use ``restore_command`` instead of WAL streaming, and may use an absolutely independent DCS cluster.
Refer to :ref:`standby_cluster` for more details.
What is a ``leader`` in Patroni?
A ``leader`` in Patroni is like a coordinator of the cluster.
In a regular Patroni cluster, the ``leader`` will be the read/write node.
In a standby Patroni cluster, the ``leader`` (AKA ``standby leader``) will be in charge of replicating from a remote Postgres node, and cascading those changes to the other members of the standby cluster.
Does Patroni require a minimum number of Postgres nodes in the cluster?
No, you can run Patroni with any number of Postgres nodes.
Remember: Patroni is decoupled from the DCS.
What does ``pause`` mean in Patroni?
Pause is an operation exposed by Patroni so the user can ask Patroni to step back in regards to Postgres management.
That is mainly useful when you want to perform maintenance on the cluster, and would like to avoid that Patroni takes decisions related with HA, like failing over to a standby when you stop the primary.
You can find more information about that in :ref:`pause`.
Automatic failover
------------------
How does the automatic failover mechanism of Patroni work?
Patroni automatic failover is based on what we call ``leader race``.
Patroni stores the cluster's status in the DCS, among them a ``leader`` lock which holds the name of the Patroni member which is the current ``leader`` of the cluster.
That ``leader`` lock has a time-to-live associated with it. If the leader node fails to update the lease of the ``leader`` lock in time, the key will eventually expire from the DCS.
When the ``leader`` lock expires, it triggers what Patroni calls a ``leader race``: all nodes start performing checks to determine if they are the best candidates for taking over the ``leader`` role.
Some of these checks include calls to the REST API of all other Patroni members.
All Patroni members that find themselves as the best candidate for taking over the ``leader`` lock will attempt to do so.
The first Patroni member that is able to take the ``leader`` lock will promote itself to a read/write node (or ``standby leader``), and the others will be configured to follow it.
Can I temporarily disable automatic failover in the Patroni cluster?
Yes, you can!
You can achieve that by temporarily pausing the cluster.
This is typically useful for performing maintenance.
When you want to resume the automatic failover of the cluster, you just need to unpause it.
You can find more information about that in :ref:`pause`.
Bootstrapping and standbys creation
-----------------------------------
How does Patroni create a primary Postgres node? What about a standby Postgres node?
By default Patroni will use ``initdb`` to bootstrap a fresh cluster, and ``pg_basebackup`` to create standby nodes from a copy of the ``leader`` member.
You can customize that behavior by writing your custom bootstrap methods, and your custom replica creation methods.
Custom methods are usually useful when you want to restore backups created by backup tools like pgBackRest or Barman, for example.
For detailed information please refer to :ref:`custom_bootstrap` and :ref:`custom_replica_creation`.
Monitoring
----------
How can I monitor my Patroni cluster?
Patroni exposes a couple handy endpoints in its :ref:`rest_api`:
* ``/metrics``: exposes monitoring metrics in a format that can be consumed by Prometheus;
* ``/patroni``: exposes the status of the cluster in a JSON format. The information shown here is very similar to what is shown by the ``/metrics`` endpoint.
You can use those endpoints to implement monitoring checks.
+1 -5
View File
@@ -10,7 +10,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
Currently supported PostgreSQL versions: 9.3 to 16.
Currently supported PostgreSQL versions: 9.3 to 15.
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the :ref:`Citus support page <citus>` in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
@@ -25,20 +25,16 @@ Currently supported PostgreSQL versions: 9.3 to 16.
installation
patroni_configuration
rest_api
patronictl
replica_bootstrap
replication_modes
standby_cluster
watchdog
pause
dcs_failsafe_mode
kubernetes
citus
existing_data
tools_integration
security
ha_multi_dc
faq
releases
CONTRIBUTING
+17 -14
View File
@@ -30,10 +30,23 @@ There are a few options available:
sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu
sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the :ref:`list of dependencies <extras>` when installing Patroni with pip.
2. Install psycopg2 from the binary package
.. code-block:: shell
.. _extras:
pip install psycopg2-binary
3. Install psycopg2 from source
.. code-block:: shell
pip install psycopg2>=2.5.4
4. Use psycopg 3.0 instead of psycopg2
.. code-block:: shell
pip install psycopg[binary]>=3.0.0
General installation for pip
----------------------------
@@ -60,22 +73,12 @@ raft
`pysyncobj` module in order to use python Raft implementation as DCS
aws
`boto3` in order to use AWS callbacks
jsonlogger
`python-json-logger` module in order to enable :ref:`logging <log_settings>` in json format
all
all of the above (except psycopg family)
psycopg
`psycopg[binary]>=3.0.0` module
psycopg2
`psycopg2>=2.5.4` module
psycopg2-binary
`psycopg2-binary` module
For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is:
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
.. code-block:: shell
pip install patroni[psycopg3,etcd3,aws]
pip install patroni[etcd,aws]
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed
independently of Patroni.
+13 -122
View File
@@ -15,7 +15,7 @@ There are 3 types of Patroni configuration:
- Global :ref:`dynamic configuration <dynamic_configuration>`.
These options are stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
Dynamic configuration can be set at any time using :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
Dynamic configuration can be set at any time using ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
If the options changed are not part of the startup configuration, they are applied asynchronously (upon the next wake up cycle)
to every node, which gets subsequently reloaded.
If the node requires a restart to apply the configuration (for `PostgreSQL parameters <https://www.postgresql.org/docs/current/view-pg-settings.html>`__ with context postmaster, if their values
@@ -24,13 +24,12 @@ There are 3 types of Patroni configuration:
- Local :ref:`configuration file <yaml_configuration>` (patroni.yml).
These options are defined in the configuration file and take precedence over dynamic configuration.
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing :ref:`patronictl_reload`. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
- :ref:`Environment configuration <environment>`.
It is possible to set/override some of the "Local" configuration parameters with environment variables.
Environment configuration is very useful when you are running in a dynamic environment and you don't know some of the parameters in advance (for example it's not possible to know your external IP address when you are running inside ``docker``).
.. _important_configuration_rules:
Important rules
---------------
@@ -71,16 +70,15 @@ There also are some parameters like **postgresql.listen**, **postgresql.data_dir
When applying the local or dynamic configuration options, the following actions are taken:
- The node first checks if there is a `postgresql.base.conf` file or if the ``custom_conf`` parameter is set.
- If the ``custom_conf`` parameter is set, the file it specifies is used as the base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and is used as the base configuration.
- If there is no ``custom_conf`` nor `postgresql.base.conf`, the original `postgresql.conf` is renamed to `postgresql.base.conf` and used as the base configuration.
- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf` and an include is set in
`postgresql.conf` to the base configuration (either `postgresql.base.conf` or the file at ``custom_conf``).
Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present or not.
- The node first checks if there is a `postgresql.base.conf` or if the ``custom_conf`` parameter is set.
- If the ``custom_conf`` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration.
- If there is no ``custom_conf``` nor `postgresql.base.conf`, the original `postgresql.conf`` is taken and renamed to postgresql.base.conf.
- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf`` and an include is set in
postgresql.conf to the used base configuration (either `postgresql.base.conf` or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
- If an option that requires restart is changed (we should look at the context in pg_settings and at the actual
values of those options), a pending_restart flag is set on that node. This flag is reset on any restart.
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
The parameters would be applied in the following order (run-time are given the highest priority):
@@ -91,7 +89,6 @@ The parameters would be applied in the following order (run-time are given the h
This allows configuration for all the nodes (2), configuration for a specific node using ``ALTER SYSTEM`` (3) and ensures that parameters essential to the running of Patroni are enforced (4), as well as leaves room for configuration tools that manage `postgresql.conf` directly without involving Patroni (1).
.. _shared_memory_gucs:
PostgreSQL parameters that touch shared memory
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -108,10 +105,10 @@ Changing these parameters require a PostgreSQL restart to take effect, and their
As explained before, Patroni restrict changing their values through :ref:`dynamic configuration <dynamic_configuration>`, which usually consists of:
1. Applying changes through :ref:`patronictl_edit_config` (or via REST API ``/config`` endpoint)
2. Restarting nodes through :ref:`patronictl_restart` (or via REST API ``/restart`` endpoint)
1. Applying changes through ``patronictl edit-config`` (or via REST API ``/config`` endpoint)
2. Restarting nodes through ``patronictl restart`` (or via REST API ``/restart`` endpoint)
**Note:** please keep in mind that you should perform a restart of the PostgreSQL nodes through :ref:`patronictl_restart` command, or via REST API ``/restart`` endpoint. An attempt to restart PostgreSQL by restarting the Patroni daemon, e.g. by executing ``systemctl restart patroni``, can cause a failover to occur in the cluster, if you are restarting the primary node.
**Note:** please keep in mind that you should perform a restart of the PostgreSQL nodes through ``patronictl restart`` command, or via REST API ``/restart`` endpoint. An attempt to restart PostgreSQL by restarting the Patroni daemon, e.g. by executing ``systemctl restart patroni``, can cause a failover to occur in the cluster, if you are restarting the primary node.
However, as those settings manage shared memory, some extra care should be taken when restarting the nodes:
@@ -145,109 +142,3 @@ Also the following Patroni configuration options **can be changed only dynamical
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its run-time values.
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
.. _validate_generate_config:
Configuration generation and validation
---------------------------------------
Patroni provides command-line interfaces for a Patroni :ref:`local configuration <yaml_configuration>` generation and validation. Using the ``patroni`` executable you can:
- Create a sample local Patroni configuration;
- Create a Patroni configuration file for the locally running PostgreSQL instance (e.g. as a preparation step for the :ref:`Patroni integration <existing_data>`);
- Validate a given Patroni configuration file.
.. _generate_sample_config:
Sample Patroni configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code:: text
patroni --generate-sample-config [configfile]
Description
"""""""""""
Generate a sample Patroni configuration file in ``yaml`` format.
Parameter values are defined using the :ref:`Environment configuration <environment>`, otherwise, if not set, the defaults used in Patroni or the ``#FIXME`` string for the values that should be later defined by the user.
Some default values are defined based on the local setup:
- **postgresql.listen**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port.
- **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port.
- **postgresql.authentication.rewind**: is only defined if the PostgreSQL version can be defined from the binary and the version is 11 or later.
- **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
- **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
Parameters
""""""""""
``configfile`` - full path to the configuration file used to store the result. If not provided, the result is sent to ``stdout``.
.. _generate_config:
Patroni configuration for a running instance
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code:: text
patroni --generate-config [--dsn DSN] [configfile]
Description
"""""""""""
Generate a Patroni configuration in ``yaml`` format for the locally running PostgreSQL instance.
Either the provided DSN (takes precedence) or PostgreSQL `environment variables <https://www.postgresql.org/docs/current/libpq-envars.html>`__ will be used for the PostgreSQL connection. If the password is not provided, it should be entered via prompt.
All the non-internal GUCs defined in the source Postgres instance, independently if they were set through a configuration file, through the postmaster command-line, or through environment variables, will be used as the source for the following Patroni configuration parameters:
- **scope**: ``cluster_name`` GUC value;
- **postgresql.listen**: ``listen_addresses`` and ``port`` GUC values;
- **postgresql.datadir**: ``data_directory`` GUC value;
- **postgresql.parameters**: ``archive_command``, ``restore_command``, ``archive_cleanup_command``, ``recovery_end_command``, ``ssl_passphrase_command``, ``hba_file``, ``ident_file``, ``config_file`` GUC values;
- **bootstrap.dcs**: all other gathered PostgreSQL GUCs.
If ``scope``, ``postgresql.listen`` or ``postgresql.datadir`` is not set from the Postgres GUCs, the respective :ref:`Environment configuration <environment>` value is used.
Other rules applied for the values definition:
- **name**: ``PATRONI_NAME`` environment variable value if set, otherwise the current machine's hostname.
- **postgresql.bin_dir**: path to the Postgres binaries gathered from the running instance.
- **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the port used for the instance connection or the ``port`` GUC value.
- **postgresql.authentication.superuser**: the configuration used for the instance connection;
- **postgresql.pg_hba**: the lines gathered from the source instance's ``hba_file``.
- **postgresql.pg_ident**: the lines gathered from the source instance's ``ident_file``.
- **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
- **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
Other parameters defined using :ref:`Environment configuration <environment>` are also included into the configuration.
Parameters
""""""""""
``configfile``
Full path to the configuration file used to store the result. If not provided, result is sent to ``stdout``.
``dsn``
Optional DSN string for the local PostgreSQL instance to get GUC values from.
Validate Patroni configuration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code:: text
patroni --validate-config [configfile]
Description
"""""""""""
Validate the given Patroni configuration and print the information about the failed checks.
Parameters
""""""""""
``configfile``
Full path to the configuration file to check. If not given or file does not exist, will try to read from the ``PATRONI_CONFIG_VARIABLE`` environment variable or, if not set, from the :ref:`Patroni environment variables <environment>`.
-1975
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,6 +32,6 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
User guide
----------
``patronictl`` supports :ref:`pause <patronictl_pause>` and :ref:`resume <patronictl_resume>` commands.
``patronictl`` supports ``pause`` and ``resume`` commands.
One can also issue a ``PATCH`` request to the ``{namespace}/{cluster}/config`` key with ``{"pause": true/false/null}``
-310
View File
@@ -3,316 +3,6 @@
Release notes
=============
Version 3.3.0
-------------
.. warning::
All older Partoni versions are not compatible with ``ydiff>=1.3``.
There are the following options available to "fix" the problem:
1. upgrade Patroni to the latest version
2. install ``ydiff<1.3`` after installing Patroni
3. install ``cdiff`` module
**New features**
- Add ability to pass ``auth_data`` to Zookeeper client (Aras Mumcuyan)
It allows to specify the authentication credentials to use for the connection.
- Add a contrib script for ``Barman`` integration (Israel Barth Rubio)
Provide an application ``patroni_barman`` that allows to perform ``Barman`` operations remotely and can be used as a custom bootstrap/custom replica method or as an ``on_role_change`` callback. Please check :ref:`here <tools_integration>` for more information.
- Support ``JSON`` log format (alisalemmi)
Apart from ``plain`` (default), Patroni now also supports ``json`` log format. Requires ``python-json-logger>=2.0.2`` library to be installed.
- Show ``pending_restart_reason`` information (Polina Bungina)
Provide extended information about the PostgreSQL parameters that caused ``pending_restart`` flag to be set. Both ``patronictl list`` and ``/patroni`` REST API endpoint now show the parameters names and their "diff" as ``pending_restart_reason``.
- Implement ``nostream`` tag (Grigory Smolkin)
If ``nostream`` tag is set to ``true``, the node will not use replication protocol to stream WAL but instead rely on archive recovery (if ``restore_command`` is configured). It also disables copying and synchronization of permanent logical replication slots on the node itself and all its cascading replicas.
**Improvements**
- Implement validation of the ``log`` section (Alexander Kukushkin)
Until now validator was not checking the correctness of the logging configuration provided.
- Improve logging for PostgreSQL parameters change (Polina Bungina)
Convert old values to a human-readable format and log information about the ``pg_controldata`` vs Patroni global configuration mismatch.
**Bugfixes**
- Properly filter out not allowed ``pg_basebackup`` options (Israel Barth Rubio)
Due to a bug, Patroni was not properly filtering out the not allowed options configured for the ``basebackup`` replica bootstrap method, when provided in the ``- setting: value`` format.
- Fix ``etcd3`` authentication error handling (Alexander Kukushkin)
Always retry one time on ``etcd3`` authentication error if authentication was not done right before executing the request. Also, do not restart watchers on reauthentication.
- Improve logic of the validator files discovery (Waynerv)
Use ``importlib`` library to discover the files with available configuration parameters when possible (for Python 3.9+). This implementation is more stable and doesn't break the Patroni distributions based on ``zip`` archives.
- Use ``target_session_attrs`` only when multiple hosts are specified in the ``standby_cluster`` section (Alexander Kukushkin)
``target_session_attrs=read-write`` is now added to the ``primary_conninfo`` on the standby leader node only when ``standby_cluster.host`` section contains multiple hosts separated by commas.
- Add compatibility code for ``ydiff`` library version 1.3+ (Alexander Kukushkin)
Patroni is relying on some API from ``ydiff`` that is not public because it is supposed to be just a terminal tool rather than a python module. Unfortunately, the API change in 1.3 broke old Patroni versions.
Version 3.2.2
-------------
**Bugfixes**
- Don't let replica restore initialize key when DCS was wiped (Alexander Kukushkin)
It was happening in the method where Patroni was supposed to take over a standalone PG cluster.
- Use consistent read when fetching just updated sync key from Consul (Alexander Kukushkin)
Consul doesn't provide any interface to immediately get ``ModifyIndex`` for the key that we just updated, therefore we have to perform an explicit read operation. Since stale reads are allowed by default, we sometimes used to get an outdated version of the key.
- Reload Postgres config if a parameter that requires restart was reset to the original value (Polina Bungina)
Previously Patroni wasn't updating the config, but only resetting the ``pending_restart``.
- Fix erroneous inverted logic of the confirmation prompt message when doing a failover to an async candidate in synchronous mode (Polina Bungina)
The problem existed only in ``patronictl``.
- Exclude leader from failover candidates in ``patronictl`` (Polina Bungina)
If the cluster is healthy, failing over to an existing leader is no-op.
- Create Citus database and extension idempotently (Alexander Kukushkin, Zhao Junwang)
It will allow to create them in the ``post_bootstrap`` script in case if there is a need to add some more dependencies to the Citus database.
- Don't filter our contradictory ``nofailover`` tag (Polina Bungina)
The configuration ``{nofailover: false, failover_priority: 0}`` set on a node didn't allow it to participate in the race, while it should, because ``nofailover`` tag should take precedence.
- Fixed PyInstaller frozen issue (Sophia Ruan)
The ``freeze_support()`` was called after ``argparse`` and as a result, Patroni wasn't able to start Postgres.
- Fixed bug in the config generator for ``patronictl`` and ``Citus`` configuration (Israel Barth Rubio)
It prevented ``patronictl`` and ``Citus`` configuration parameters set via environment variables from being written into the generated config.
- Restore recovery GUCs and some Patroni-managed parameters when joining a running standby (Alexander Kukushkin)
Patroni was failing to restart Postgres v12 onwards with an error about missing ``port`` in one of the internal structures.
- Fixes around ``pending_restart`` flag (Polina Bungina)
Don't expose ``pending_restart`` when in custom bootstrap with ``recovery_target_action = promote`` or when someone changed ``hot_standby`` or ``wal_log_hints`` using for example ``ALTER SYSTEM``.
Version 3.2.1
-------------
**Bugfixes**
- Limit accepted values for ``--format`` argument in ``patronictl`` (Alexander Kukushkin)
It used to accept any arbitrary string and produce no output if the value wasn't recognized.
- Verify that replica nodes received checkpoint LSN on shutdown before releasing the leader key (Alexander Kukushkin)
Previously in some cases, we were using LSN of the SWITCH record that is followed by CHECKPOINT (if archiving mode is enabled). As a result the former primary sometimes had to do ``pg_rewind``, but there would be no data loss involved.
- Do a real HTTP request when performing node name uniqueness check (Alexander Kukushkin)
When running Patroni in containers it is possible that the traffic is routed using ``docker-proxy``, which listens on the port and accepts incoming connections. It was causing false positives.
- Fixed Citus support with Etcd v2 (Alexander Kukushkin)
Patroni was failing to deploy a new Citus cluster with Etcd v2.
- Fixed ``pg_rewind`` behavior with Postgres v16+ (Alexander Kukushkin)
The error message format of ``pg_waldump`` changed in v16 which caused ``pg_rewind`` to be called by Patroni even when it was not necessary.
- Fixed bug with custom bootstrap (Alexander Kukushkin)
Patroni was falsely applying ``--command`` argument, which is a bootstrap command itself.
- Fixed the issue with REST API health check endpoints (Sophia Ruan)
There were chances that after Postgres restart it could return ``unknown`` state for Postgres because connections were not properly closed.
- Cache ``postgres --describe-config`` output results (Waynerv)
They are used to figure out which GUCs are available to validate PostgreSQL configuration and we don't expect this list to change while Patroni is running.
Version 3.2.0
-------------
**Deprecation notice**
- The ``bootstrap.users`` support will be removed in version 4.0.0. If you need to create users after deploying a new cluster please use the ``bootstrap.post_bootstrap`` hook for that.
**Breaking changes**
- Enforce ``loop_wait + 2*retry_timeout <= ttl`` rule and hard-code minimal possible values (Alexander Kukushkin)
Minimal values: ``loop_wait=2``, ``retry_timeout=3``, ``ttl=20``. In case values are smaller or violate the rule they are adjusted and a warning is written to Patroni logs.
**New features**
- Failover priority (Mark Pekala)
With the help of ``tags.failover_priority`` it's now possible to make a node more preferred during the leader race. More details in the documentation (ref tags).
- Implemented ``patroni --generate-config [--dsn DSN]`` and ``patroni --generate-sample-config`` (Polina Bungina)
It allows to generate a config file for the running PostgreSQL cluster or a sample config file for the new Patroni cluster.
- Use a dedicated connection to Postgres for Patroni REST API (Alexander Kukushkin)
It helps to avoid blocking the main heartbeat loop if the system is under stress.
- Enrich some endpoints with the ``name`` of the node (sskserk)
For the monitoring endpoint ``name`` is added next to the ``scope`` and for metrics endpoint the ``name`` is added to tags.
- Ensure strict failover/switchover difference (Polina Bungina)
Be more precise in log messages and allow failing over to an asynchronous node in a healthy synchronous cluster.
- Make permanent physical replication slots behave similarly to permanent logical slots (Alexander Kukushkin)
Create permanent physical replication slots on all nodes that are allowed to become the leader and use ``pg_replication_slot_advance()`` function to advance ``restart_lsn`` for slots on standby nodes.
- Add capability of specifying namespace through ``--dcs`` argument in ``patronictl`` (Israel Barth Rubio)
It could be handy if ``patronictl`` is used without a configuration file.
- Add support for additional parameters in custom bootstrap configuration (Israel Barth Rubio)
Previously it was only possible to add custom arguments to the ``command`` and now one could list them as a mapping.
**Improvements**
- Set ``citus.local_hostname`` GUC to the same value which is used by Patroni to connect to the Postgres (Alexander Kukushkin)
There are cases when Citus wants to have a connection to the local Postgres. By default it uses ``localhost``, which is not always available.
**Bugfixes**
- Ignore ``synchronous_mode`` setting in a standby cluster (Polina Bungina)
Postgres doesn't support cascading synchronous replication and not ignoring ``synchronous_mode`` was breaking a switchover in a standby cluster.
- Handle SIGCHLD for ``on_reload`` callback (Alexander Kukushkin)
Not doing so results in a zombie process, which is reaped only when the next ``on_reload`` is executed.
- Handle ``AuthOldRevision`` error when working with Etcd v3 (Alexander Kukushkin, Kenny Do)
The error is raised if Etcd is configured to use JWT and when the user database in Etcd is updated.
Version 3.1.2
-------------
**Bugfixes**
- Fixed bug with ``wal_keep_size`` checks (Alexander Kukushkin)
The ``wal_keep_size`` is a GUC that normally has a unit and Patroni was failing to cast its value to ``int``. As a result the value of ``bootstrap.dcs`` was not written to the ``/config`` key afterwards.
- Detect and resolve inconsistencies between ``/sync`` key and ``synchronous_standby_names`` (Alexander Kukushkin)
Normally, Patroni updates ``/sync`` and ``synchronous_standby_names`` in a very specific order, but in case of a bug or when someone manually reset ``synchronous_standby_names``, Patroni was getting into an inconsistent state. As a result it was possible that the failover happens to an asynchronous node.
- Read GUC's values when joining running Postgres (Alexander Kukushkin)
When restarted in ``pause``, Patroni was discarding the ``synchronous_standby_names`` GUC from the ``postgresql.conf``. To solve it and avoid similar issues, Patroni will read GUC's value if it is joining an already running Postgres.
- Silenced annoying warnings when checking for node uniqueness (Alexander Kukushkin)
``WARNING`` messages are produced by ``urllib3`` if Patroni is quickly restarted.
Version 3.1.1
-------------
**Bugfixes**
- Reset failsafe state on promote (ChenChangAo)
If switchover/failover happened shortly after failsafe mode had been activated, the newly promoted primary was demoting itself after failsafe becomes inactive.
- Silence useless warnings in ``patronictl`` (Alexander Kukushkin)
If ``patronictl`` uses the same patroni.yaml file as Patroni and can access ``PGDATA`` directory it might have been showing annoying warnings about incorrect values in the global configuration.
- Explicitly enable synchronous mode for a corner case (Alexander Kukushkin)
Synchronous mode effectively was never activated if there are no replicas streaming from the primary.
- Fixed bug with ``0`` integer values validation (Israel Barth Rubio)
In most cases, it didn't cause any issues, just warnings.
- Don't return logical slots for standby cluster (Alexander Kukushkin)
Patroni can't create logical replication slots in the standby cluster, thus they should be ignored if they are defined in the global configuration.
- Avoid showing docstring in ``patronictl --help`` output (Israel Barth Rubio)
The ``click`` module needs to get a special hint for that.
- Fixed bug with ``kubernetes.standby_leader_label_value`` (Alexander Kukushkin)
This feature effectively never worked.
- Returned cluster system identifier to the ``patronictl list`` output (Polina Bungina)
The problem was introduced while implementing the support for Citus, where we need to hide the identifier because it is different for coordinator and all workers.
- Override ``write_leader_optime`` method in Kubernetes implementation (Alexander Kukushkin)
The method is supposed to write shutdown LSN to the leader Endpoint/ConfigMap when there are no healthy replicas available to become the new primary.
- Don't start stopped postgres in pause (Alexander Kukushkin)
Due to a race condition, Patroni was falsely assuming that the standby should be restarted because some recovery parameters (``primary_conninfo`` or similar) were changed.
- Fixed bug in ``patronictl query`` command (Israel Barth Rubio)
It didn't work when only ``-m`` argument was provided or when none of ``-r`` or ``-m`` were provided.
- Properly treat integer parameters that are used in the command line to start postgres (Polina Bungina)
If values are supplied as strings and not casted to integer it was resulting in an incorrect calculation of ``max_prepared_transactions`` based on ``max_connections`` for Citus clusters.
- Don't rely on ``pg_stat_wal_receiver`` when deciding on ``pg_rewind`` (Alexander Kukushkin)
It could happen that ``received_tli`` reported by ``pg_stat_wal_recevier`` is ahead of the actual replayed timeline, while the timeline reported by ``DENTIFY_SYSTEM`` via replication connection is always correct.
Version 3.1.0
-------------
+61 -55
View File
@@ -1,5 +1,3 @@
.. _replica_imaging_and_bootstrap:
Replica imaging and bootstrap
=============================
@@ -45,49 +43,19 @@ in the configuration files, Patroni supplies two cluster-specific ones:
Passing these two additional flags can be disabled by setting a special ``no_params`` parameter to ``True``.
If the bootstrap script returns ``0``, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
If the bootstrap script returns 0, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
of the intermediate steps fail, or the script returns a non-zero value, Patroni assumes that the bootstrap has failed,
cleans up after itself and releases the initialize lock to give another node the opportunity to bootstrap.
If a ``recovery_conf`` block is defined in the same section as the custom bootstrap method, Patroni will generate a
``recovery.conf`` before starting the newly bootstrapped instance (or set the recovery settings on Postgres configuration if
running PostgreSQL >= 12).
Typically, such recovery configuration should contain at least one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
``recovery.conf`` before starting the newly bootstrapped instance. Typically, such recovery.conf should contain at least
one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists (PostgreSQL <= 11).
Similarly, in that case Patroni will not remove the existing ``recovery.signal`` or ``standby.signal`` if either exists, nor will it override the configured recovery settings (PostgreSQL >= 12).
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate recovery configuration for you.
Besides that, any additional key/value pairs informed in the custom bootstrap method configuration will be passed as arguments to ``command`` in the format ``--name=value``. For example:
.. code:: YAML
bootstrap:
method: <custom_bootstrap_method_name>
<custom_bootstrap_method_name>:
command: <path_to_custom_bootstrap_script>
arg1: value1
arg2: value2
Makes the configured ``command`` to be called additionally with ``--arg1=value1 --arg2=value2`` command-line arguments.
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists.
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate ``recovery.conf`` for you.
.. 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:
@@ -142,24 +110,6 @@ example: pgbackrest
basebackup:
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
stop on the first one that returns 0. Each method should define a separate section in the configuration file, listing the command
@@ -219,3 +169,59 @@ and
- 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.
.. _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 ``patronictl list`` or ``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.
+1 -1
View File
@@ -53,7 +53,7 @@ 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
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 via Patroni REST interface. See :ref:`dynamic configuration <dynamic_configuration>` for instructions.
+7 -10
View File
@@ -3,7 +3,7 @@
Patroni REST API
================
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
Health check endpoints
----------------------
@@ -426,7 +426,6 @@ Cluster status endpoints
]
]
.. _config_endpoint:
Config endpoint
---------------
@@ -620,9 +619,9 @@ In the JSON body of the ``POST`` request you must specify the ``candidate`` fiel
:ref:`Be very careful <failover_healthcheck>` when using this endpoint, as this can cause data loss in certain situations. In most cases, :ref:`the switchover endpoint <switchover_api>` satisfies the administrator's needs.
``POST /switchover`` and ``POST /failover`` endpoints are used by :ref:`patronictl_switchover` and :ref:`patronictl_failover`, respectively.
``POST /switchover`` and ``POST /failover`` endpoints are used by ``patronictl switchover`` and ``patronictl failover``, respectively.
``DELETE /switchover`` is used by :ref:`patronictl flush cluster-name switchover <patronictl_flush_parameters>`.
``DELETE /switchover`` is used by ``patronictl flush <cluster-name> switchover``.
.. list-table:: Failover/Switchover comparison
:widths: 25 25 25
@@ -667,7 +666,6 @@ There are a couple of checks that a member of a cluster should pass to be able t
- its lag exceeds the maximum replication lag allowed;
- it has the timeline number smaller than the last known cluster timeline.
.. _restart_endpoint:
Restart endpoint
----------------
@@ -682,16 +680,15 @@ Restart endpoint
- ``DELETE /restart``: delete the scheduled restart
``POST /restart`` and ``DELETE /restart`` endpoints are used by :ref:`patronictl_restart` and :ref:`patronictl flush cluster-name restart <patronictl_flush_parameters>` respectively.
``POST /restart`` and ``DELETE /restart`` endpoints are used by ``patronictl restart`` and ``patronictl flush <cluster-name> restart`` respectively.
.. _reload_endpoint:
Reload endpoint
---------------
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of :ref:`patronictl_restart`.
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of ``patronictl restart``.
The reload endpoint is used by :ref:`patronictl_reload`.
The reload endpoint is used by ``patronictl reload``.
Reinitialize endpoint
@@ -701,4 +698,4 @@ Reinitialize endpoint
The call might fail if Patroni is in a loop trying to recover (restart) a failed Postgres. In order to overcome this problem one can specify ``{"force":true}`` in the request body.
The reinitialize endpoint is used by :ref:`patronictl_reinit`.
The reinitialize endpoint is used by ``patronictl reinit``.
+3 -3
View File
@@ -9,7 +9,7 @@ A Patroni cluster has two interfaces to be protected from unauthorized access: t
Protecting DCS
==============
Patroni and :ref:`patronictl` both store and retrieve data to/from the DCS.
Patroni and patronictl both store and retrieve data to/from the DCS.
Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself.
@@ -22,7 +22,7 @@ Protecting the REST API
Protecting the REST API is a more complicated task.
The Patroni REST API is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
The Patroni REST API is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
From the point of view of security, REST API contains safe (``GET`` requests, only retrieve information) and unsafe (``PUT``, ``POST``, ``PATCH`` and ``DELETE`` requests, change the state of nodes) endpoints.
@@ -32,6 +32,6 @@ When TLS for the REST API is enabled and a PKI is established, mutual authentica
The ``restapi`` section parameters enable TLS client authentication to the server. Depending on the value of the ``verify_client`` parameter, the API server requires a successful client certificate verification for both safe and unsafe API calls (``verify_client: required``), or only for unsafe API calls (``verify_client: optional``), or for no API calls (``verify_client: none``).
The ``ctl`` section parameters enable TLS server authentication to the client (the :ref:`patronictl` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings <patronictl_settings>` for a detailed description of the TLS client parameters.
The ``ctl`` section parameters enable TLS server authentication to the client (the ``patronictl`` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings <patronictl_settings>` for a detailed description of the TLS client parameters.
Protecting the PostgreSQL database proper from unauthorized access is beyond the scope of this document and is covered in https://www.postgresql.org/docs/current/client-authentication.html
-78
View File
@@ -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.
-64
View File
@@ -1,64 +0,0 @@
.. _tools_integration:
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.
+23 -37
View File
@@ -11,22 +11,12 @@ Global/Universal
- **namespace**: path within the configuration store where Patroni will keep information about the cluster. Default value: "/service"
- **scope**: cluster name
.. _log_settings:
Log
---
- **type**: sets the format of logs. Can be either **plain** or **json**. To use **json** format, you must have the :ref:`jsonlogger <extras>` installed. The default value is **plain**.
- **level**: sets the general logging level. Default value is **INFO** (see `the docs for Python logging <https://docs.python.org/3.6/library/logging.html#levels>`_)
- **traceback\_level**: sets the level where tracebacks will be visible. Default value is **ERROR**. Set it to **DEBUG** if you want to see tracebacks only if you enable **log.level=DEBUG**.
- **format**: sets the log formatting string. If the log type is **plain**, the log format should be a string. Refer to
`the LogRecord attributes <https://docs.python.org/3.6/library/logging.html#logrecord-attributes>`_ for
available attributes. If the log type is **json**, the log format can be a list in addition to a string. Each list
item should correspond to LogRecord attributes. Be cautious that only the field name is required, and the **%(**
and **)** should be omitted. If you wish to print a log field with a different key name, use a dictionary where
the dictionary key is the log field, and the value is the name of the field you want to be printed in the log.
Default value is **%(asctime)s %(levelname)s: %(message)s**
- **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>`_)
- **dateformat**: sets the datetime formatting string. (see the `formatTime() documentation <https://docs.python.org/3.6/library/logging.html#logging.Formatter.formatTime>`_)
- **static_fields**: add additional fields to the log. This option is only available when the log type is set to **json**.
- **max\_queue\_size**: Patroni is using two-step logging. Log records are written into the in-memory queue and there is a separate thread which pulls them from the queue and writes to stderr or file. The maximum size of the internal queue is limited by default by **1000** records, which is enough to keep logs for the past 1h20m.
- **dir**: Directory to write application logs to. The directory must exist and be writable by the user executing Patroni. If you set this value, the application will retain 4 25MB logs by default. You can tune those retention values with `file_num` and `file_size` (see below).
- **file\_num**: The number of application logs to retain.
@@ -36,20 +26,6 @@ Log
- **patroni.postmaster: WARNING**
- **urllib3: DEBUG**
Here is an example of how to config patroni to log in json format.
.. code:: YAML
log:
type: json
format:
- message
- module
- asctime: '@timestamp'
- levelname: level
static_fields:
app: patroni
.. _bootstrap_settings:
Bootstrap configuration
@@ -58,7 +34,7 @@ Bootstrap configuration
.. note::
Once Patroni has initialized the cluster for the first time and settings have been stored in the DCS, all future
changes to the ``bootstrap.dcs`` section of the YAML configuration will not take any effect! If you want to change
them please use either :ref:`patronictl_edit_config` or the Patroni :ref:`REST API <rest_api>`.
them please use either ``patronictl edit-config`` or the Patroni :ref:`REST API <rest_api>`.
- **bootstrap**:
@@ -73,8 +49,24 @@ Bootstrap configuration
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
- **- encoding: UTF8**: default encoding for new databases.
- **- locale: UTF8**: default locale for new databases.
- **users**: Some additional users which need to be created after initializing new cluster, see :ref:`Bootstrap users configuration <bootstrap_users_configuration>` below.
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
.. _bootstrap_users_configuration:
Bootstrap users configuration
=============================
Users which need to be created after initializing the cluster:
- **admin**: the name of user
- **password**: (optional) password for the user
- **options**: list of options for CREATE USER statement
- **- createrole**
- **- createdb**
.. _citus_settings:
Citus
@@ -157,7 +149,6 @@ ZooKeeper
- **key_password**: (optional) The client key password.
- **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]}``.
- **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::
It is required to install ``kazoo>=2.6.0`` to support SSL.
@@ -375,10 +366,10 @@ CTL
- **authentication**:
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter.
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
- **insecure**: Allow connections to REST API without verifying SSL certs.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter.
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
- **certfile**: Specifies the file with the client certificate in the PEM format.
- **keyfile**: Specifies the file with the client secret key in the PEM format.
- **keyfile\_password**: Specifies a password for decrypting the client keyfile.
@@ -393,16 +384,11 @@ Watchdog
Tags
----
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
- **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.
- **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::
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.
In addition to these predefined tags, you can also add your own ones:
@@ -411,4 +397,4 @@ In addition to these predefined tags, you can also add your own ones:
- **key3**: ``1.4``
- **key4**: ``"RandomString"``
Tags are visible in the :ref:`REST API <rest_api>` and :ref:`patronictl_list` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
Tags are visible in the :ref:`REST API <rest_api>` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
-1
View File
@@ -6,7 +6,6 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--datadir", required=True)
parser.add_argument("--sourcedir", required=True)
parser.add_argument("--test-argument", required=True)
args, _ = parser.parse_known_args()
shutil.copytree(args.sourcedir, args.datadir)
+1 -1
View File
@@ -82,4 +82,4 @@ Feature: basic replication
@reject-duplicate-name
Scenario: check graceful rejection when two nodes have the same name
Given I start duplicate postgres0 on port 8011
Then there is one of ["Can't start; there is already a node named 'postgres0' running"] CRITICAL in the dup-postgres0 patroni log after 5 seconds
Then there is a "Can't start; there is already a node named 'postgres0' running" CRITICAL in the dup-postgres0 patroni log
+1 -1
View File
@@ -68,6 +68,6 @@ Feature: citus
And I receive a response output "+ttl: 20"
Then postgres4 is registered in the postgres2 as the primary in group 2 after 5 seconds
When I shut down postgres4
Then there is a transaction in progress on postgres0 changing pg_dist_node after 5 seconds
Then There is a transaction in progress on postgres0 changing pg_dist_node
When I run patronictl.py restart batman postgres2 --group 1 --force
Then a transaction finishes in 20 seconds
+11 -42
View File
@@ -4,14 +4,14 @@ Feature: dcs failsafe mode
Scenario: check failsafe mode can be successfully enabled
Given I start postgres0
And postgres0 is a leader after 10 seconds
Then "config" key in DCS has ttl=30 after 10 seconds
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 3, "failsafe_mode": true}
And I sleep for 3 seconds
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 5, "failsafe_mode": true}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/failsafe contains postgres0 after 10 seconds
When I issue a GET request to http://127.0.0.1:8008/failsafe
Then I receive a response code 200
And I receive a response postgres0 http://127.0.0.1:8008/patroni
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}},"slots":{"dcs_slot_1": null,"postgres0":null}}
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}}
Then I receive a response code 200
When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots": {"dcs_slot_0": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
Then I receive a response code 200
@@ -28,6 +28,7 @@ Feature: dcs failsafe mode
When I do a backup of postgres0
And I shut down postgres0
When I start postgres1 in a cluster batman from backup with no_leader
And I sleep for 2 seconds
Then postgres1 role is the replica after 12 seconds
Scenario: check leader and replica are both in /failsafe key after leader is back
@@ -44,73 +45,41 @@ Feature: dcs failsafe mode
@dcs-failsafe
@slot-advance
Scenario: check leader and replica are functioning while DCS is down
Given I get all changes from physical slot dcs_slot_1 on postgres0
Then physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds
And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds
Given logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds
And DCS is down
Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds
Then postgres0 role is the primary after 10 seconds
And postgres1 role is the replica after 2 seconds
And replication works from postgres0 to postgres1 after 10 seconds
When I get all changes from logical slot dcs_slot_0 on postgres0
And I get all changes from physical slot dcs_slot_1 on postgres0
Then logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds
And physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds
And I get all changes from logical slot dcs_slot_0 on postgres0
And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds
@dcs-failsafe
Scenario: check primary is demoted when one replica is shut down and DCS is down
Given DCS is down
And I kill postgres1
And I kill postmaster on postgres1
And I sleep for 2 seconds
Then postgres0 role is the replica after 12 seconds
@dcs-failsafe
Scenario: check known replica is promoted when leader is down and DCS is up
Given I kill postgres0
And I shut down postmaster on postgres0
Given I shut down postgres0
And DCS is up
When I start postgres1
Then "members/postgres1" key in DCS has state=running after 10 seconds
And postgres1 role is the primary after 25 seconds
@dcs-failsafe
Scenario: scale to three-node cluster
Scenario: check three-node cluster is functioning while DCS is down
Given I start postgres0
And I start postgres2
Then "members/postgres2" key in DCS has state=running after 10 seconds
And "members/postgres0" key in DCS has state=running after 20 seconds
And Response on GET http://127.0.0.1:8008/failsafe contains postgres2 after 10 seconds
And replication works from postgres1 to postgres0 after 10 seconds
And replication works from postgres1 to postgres2 after 10 seconds
@dcs-failsafe
@slot-advance
Scenario: make sure permanent slots exist on replicas
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"slots":{"dcs_slot_0":null,"dcs_slot_2":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
When I get all changes from physical slot dcs_slot_1 on postgres1
Then physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
@dcs-failsafe
Scenario: check three-node cluster is functioning while DCS is down
Given DCS is down
Then Response on GET http://127.0.0.1:8009/primary contains failsafe_mode_is_active after 12 seconds
Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds
Then postgres1 role is the primary after 10 seconds
And postgres0 role is the replica after 2 seconds
And postgres2 role is the replica after 2 seconds
@dcs-failsafe
@slot-advance
Scenario: check that permanent slots are in sync between nodes while DCS is down
Given replication works from postgres1 to postgres0 after 10 seconds
And replication works from postgres1 to postgres2 after 10 seconds
When I get all changes from logical slot dcs_slot_2 on postgres1
And I get all changes from physical slot dcs_slot_1 on postgres1
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
And physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
+24 -37
View File
@@ -162,10 +162,9 @@ class PatroniController(AbstractController):
def stop(self, kill=False, timeout=15, postgres=False):
if postgres:
mode = 'i' if kill else 'f'
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-m' + mode, '-w'])
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-mi', '-w'])
super(PatroniController, self).stop(kill, timeout)
if isinstance(self._context.dcs_ctl, KubernetesController) and not kill:
if isinstance(self._context.dcs_ctl, KubernetesController):
self._context.dcs_ctl.delete_pod(self._name[8:])
if self.watchdog:
self.watchdog.stop()
@@ -245,10 +244,6 @@ class PatroniController(AbstractController):
self.recursive_update(config, custom_config)
self.recursive_update(config, {
'log': {
'format': '%(asctime)s %(levelname)s [%(pathname)s:%(lineno)d - %(funcName)s]: %(message)s',
'loggers': {'patroni.postgresql.callback_executor': 'DEBUG'}
},
'bootstrap': {
'dcs': {
'loop_wait': 2,
@@ -256,18 +251,10 @@ class PatroniController(AbstractController):
'parameters': {
'wal_keep_segments': 100,
'archive_mode': 'on',
'archive_command':
(PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
+ ' --mode archive '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data',
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('\\', '/')
'archive_command': (PatroniPoolController.ARCHIVE_RESTORE_SCRIPT
+ ' --mode archive '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self._work_directory, 'data', 'wal_archive'))
}
}
}
@@ -662,10 +649,9 @@ class KubernetesController(AbstractExternalDcsController):
try:
if group is not None:
scope = '{0}-{1}'.format(scope, group)
rkey = 'leader' if key in ('status', 'failsafe') else key
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(rkey, '-' + rkey)
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(key, '-' + key)
e = self._api.read_namespaced_endpoints(ep, self._namespace)
if key not in ('sync', 'status', 'failsafe'):
if key != 'sync':
return e.metadata.annotations[key]
else:
return json.dumps(e.metadata.annotations)
@@ -701,7 +687,7 @@ class ZooKeeperController(AbstractExternalDcsController):
self._client = kazoo.client.KazooClient()
def process_name(self):
return "java .*zookeeper"
return "zookeeper"
def query(self, key, scope='batman', group=None):
import kazoo.exceptions
@@ -900,28 +886,22 @@ class PatroniPoolController(object):
}
self.start(to_name, custom_config=custom_config)
def backup_restore_config(self, params=None):
return {
'command': (self.BACKUP_RESTORE_SCRIPT
+ ' --sourcedir=' + os.path.join(self.patroni_path, 'data', 'basebackup')).replace('\\', '/'),
'test-argument': 'test-value', # test config mapping approach on custom bootstrap/replica creation
**(params or {}),
}
def bootstrap_from_backup(self, name, cluster_name):
custom_config = {
'scope': cluster_name,
'bootstrap': {
'method': 'backup_restore',
'backup_restore': self.backup_restore_config({
'backup_restore': {
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir='
+ os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'recovery_conf': {
'recovery_target_action': 'promote',
'recovery_target_timeline': 'latest',
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
+ '--dirname {} --filename %f --pathname %p').format(
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
},
})
}
}
},
'postgresql': {
'authentication': {
@@ -936,8 +916,17 @@ class PatroniPoolController(object):
custom_config = {
'scope': cluster_name,
'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'],
'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
'no_leader_bootstrap': {
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir='
+ os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
'no_leader': '1'
}
}
}
self.start(name, custom_config=custom_config)
@@ -1076,8 +1065,6 @@ def before_all(context):
context.keyfile = os.path.join(context.pctl.output_dir, 'patroni.key')
context.certfile = os.path.join(context.pctl.output_dir, 'patroni.crt')
try:
if sys.platform == 'darwin' and 'GITHUB_ACTIONS' in os.environ:
raise Exception
with open(os.devnull, 'w') as null:
ret = subprocess.call(['openssl', 'req', '-nodes', '-new', '-x509', '-subj', '/CN=batman.patroni',
'-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', context.keyfile,
+13 -13
View File
@@ -25,10 +25,10 @@ Feature: ignored slots
# but Patroni can actually end up dropping them almost immediately, so it's helpful
# to verify they exist before we begin testing whether they persist through failover
# cycles.
Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
When I start postgres0
Then "members/postgres0" key in DCS has role=replica after 10 seconds
@@ -46,16 +46,16 @@ Feature: ignored slots
And "members/postgres1" key in DCS has role=replica after 10 seconds
# give Patroni time to sync replication slots
And I sleep for 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
And postgres1 does not have a replication slot named dummy_slot
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
And postgres1 does not have a logical replication slot named dummy_slot
# 3. After a failover the server (now a primary) still has the slot.
When I shut down postgres0
Then "members/postgres1" key in DCS has role=master after 10 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
-18
View File
@@ -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 -2
View File
@@ -68,7 +68,6 @@ Scenario: check API requests for the primary-replica pair in the pause mode
When I kill postmaster on postgres1
And I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 503
And "members/postgres1" key in DCS has state=stopped after 10 seconds
When I run patronictl.py restart batman postgres1 --force
Then I receive a response returncode 0
Then replication works from postgres0 to postgres1 after 20 seconds
@@ -77,7 +76,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
Then I receive a response code 200
And I receive a response state running
And I receive a response role replica
When I run patronictl.py reinit batman postgres1 --force --wait
When I run patronictl.py reinit batman postgres1 --force
Then I receive a response returncode 0
And I receive a response output "Success: reinitialize for member postgres1"
And postgres1 role is the secondary after 30 seconds
-75
View File
@@ -1,75 +0,0 @@
Feature: permanent slots
Scenario: check that physical permanent slots are created
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 {"slots":{"test_physical":0,"postgres0":0,"postgres1":0,"postgres3":0},"postgresql":{"parameters":{"wal_level":"logical"}}}
Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/config contains slots after 10 seconds
When I start postgres1
And I start postgres2
And I configure and start postgres3 with a tag replicatefrom postgres2
Then postgres0 has a physical replication slot named test_physical after 10 seconds
And postgres0 has a physical replication slot named postgres1 after 10 seconds
And postgres0 has a physical replication slot named postgres2 after 10 seconds
And postgres2 has a physical replication slot named postgres3 after 10 seconds
@slot-advance
Scenario: check that logical permanent slots are created
Given I run patronictl.py restart batman postgres0 --force
And I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
Then postgres0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
@slot-advance
Scenario: check that permanent slots are created on replicas
Given postgres1 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds
And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds
And postgres1 has a physical replication slot named test_physical after 2 seconds
And postgres2 has a physical replication slot named test_physical after 2 seconds
And postgres3 has a physical replication slot named test_physical after 2 seconds
@slot-advance
Scenario: check permanent physical slots that match with member names
Given postgres0 has a physical replication slot named postgres3 after 2 seconds
And postgres1 has a physical replication slot named postgres0 after 2 seconds
And postgres1 has a physical replication slot named postgres3 after 2 seconds
And postgres2 has a physical replication slot named postgres0 after 2 seconds
And postgres2 has a physical replication slot named postgres3 after 2 seconds
And postgres2 has a physical replication slot named postgres1 after 2 seconds
And postgres1 does not have a replication slot named postgres2
And postgres3 does not have a replication slot named postgres2
@slot-advance
Scenario: check that permanent slots are advanced on replicas
Given I add the table replicate_me to postgres0
When I get all changes from logical slot test_logical on postgres0
And I get all changes from physical slot test_physical on postgres0
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
And Physical slot test_physical is in sync between postgres0 and postgres1 after 10 seconds
And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds
And Physical slot test_physical is in sync between postgres0 and postgres2 after 10 seconds
And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds
And Physical slot test_physical is in sync between postgres0 and postgres3 after 10 seconds
And Physical slot postgres1 is in sync between postgres0 and postgres2 after 10 seconds
And Physical slot postgres3 is in sync between postgres2 and postgres0 after 20 seconds
And Physical slot postgres3 is in sync between postgres2 and postgres1 after 10 seconds
And postgres1 does not have a replication slot named postgres2
And postgres3 does not have a replication slot named postgres2
@slot-advance
Scenario: check that only permanent slots are written to the /status key
Given "status" key in DCS has test_physical in slots
And "status" key in DCS has postgres0 in slots
And "status" key in DCS has postgres1 in slots
And "status" key in DCS does not have postgres2 in slots
And "status" key in DCS has postgres3 in slots
Scenario: check permanent physical replication slot after failover
Given I shut down postgres3
And I shut down postgres2
And I shut down postgres0
Then postgres1 has a physical replication slot named test_physical after 10 seconds
And postgres1 has a physical replication slot named postgres0 after 10 seconds
And postgres1 has a physical replication slot named postgres3 after 10 seconds
-39
View File
@@ -1,39 +0,0 @@
Feature: priority replication
We should check that we can give nodes priority during failover
Scenario: check failover priority 0 prevents leaderships
Given I configure and start postgres0 with a tag failover_priority 1
And I configure and start postgres1 with a tag failover_priority 0
Then replication works from postgres0 to postgres1 after 20 seconds
When I shut down postgres0
And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres1 patroni log after 5 seconds
Then postgres1 role is the secondary after 10 seconds
When I start postgres0
Then postgres0 role is the primary after 10 seconds
Scenario: check higher failover priority is respected
Given I configure and start postgres2 with a tag failover_priority 1
And I configure and start postgres3 with a tag failover_priority 2
Then replication works from postgres0 to postgres2 after 20 seconds
And replication works from postgres0 to postgres3 after 20 seconds
When I shut down postgres0
Then postgres3 role is the primary after 10 seconds
And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds
Scenario: check conflicting configuration handling
When I set nofailover tag in postgres2 config
And I issue an empty POST request to http://127.0.0.1:8010/reload
Then I receive a response code 202
And there is one of ["Conflicting configuration between nofailover: True and failover_priority: 1. Defaulting to nofailover: True"] WARNING in the postgres2 patroni log after 5 seconds
And "members/postgres2" key in DCS has tags={'failover_priority': '1', 'nofailover': True} after 10 seconds
When I issue a POST request to http://127.0.0.1:8010/failover with {"candidate": "postgres2"}
Then I receive a response code 412
And I receive a response text "failover is not possible: no good candidates have been found"
When I reset nofailover tag in postgres1 config
And I issue an empty POST request to http://127.0.0.1:8009/reload
Then I receive a response code 202
And there is one of ["Conflicting configuration between nofailover: False and failover_priority: 0. Defaulting to nofailover: False"] WARNING in the postgres1 patroni log after 5 seconds
And "members/postgres1" key in DCS has tags={'failover_priority': '0', 'nofailover': False} after 10 seconds
And I issue a POST request to http://127.0.0.1:8009/failover with {"candidate": "postgres1"}
Then I receive a response code 200
And postgres1 role is the primary after 10 seconds
-2
View File
@@ -14,8 +14,6 @@ Feature: recovery
Then I receive a response code 200
And I receive a response role master
And I receive a response timeline 1
And "members/postgres0" key in DCS has state=running after 12 seconds
And replication works from postgres0 to postgres1 after 15 seconds
Scenario: check immediate failover when master_start_timeout=0
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"master_start_timeout": 0}
+14 -16
View File
@@ -22,11 +22,14 @@ Feature: standby cluster
Scenario: check permanent logical slots are synced to the replica
Given I run patronictl.py restart batman postgres1 --force
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
When I add the table replicate_me to postgres1
And I get all changes from logical slot test_logical on postgres1
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
Scenario: Detach exiting node from the cluster
When I shut down postgres1
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/
Then I receive a response code 200
@@ -47,26 +50,21 @@ Feature: standby cluster
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
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
When I issue a GET request to http://127.0.0.1:8010/patroni
Then I receive a response code 200
And I receive a response replication_state streaming
And postgres1 does not have a replication slot named test_logical
Scenario: check switchover
Given I run patronictl.py switchover batman1 --force
Then Status code on GET http://127.0.0.1:8010/standby_leader is 200 after 10 seconds
And postgres1 is replicating from postgres2 after 32 seconds
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
And postgres1 does not have a logical replication slot named test_logical
Scenario: check failover
When I kill postgres2
And I kill postmaster on postgres2
Then postgres1 is replicating from postgres0 after 32 seconds
And Status code on GET http://127.0.0.1:8009/standby_leader is 200 after 10 seconds
When I issue a GET request to http://127.0.0.1:8009/primary
When I kill postgres1
And I kill postmaster on postgres1
Then postgres2 is replicating from postgres0 after 32 seconds
When I issue a GET request to http://127.0.0.1:8010/primary
Then I receive a response code 503
And I sleep for 3 seconds
When I issue a GET request to http://127.0.0.1:8010/standby_leader
Then I receive a response code 200
And I receive a response role standby_leader
And replication works from postgres0 to postgres1 after 15 seconds
And there is a postgres1_cb.log with "on_role_change replica batman1\non_role_change standby_leader batman1" in postgres1 data directory
And replication works from postgres0 to postgres2 after 15 seconds
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
+10 -27
View File
@@ -1,4 +1,3 @@
import json
import patroni.psycopg as pg
from behave import step, then
@@ -36,27 +35,16 @@ def kill_patroni(context, name):
return context.pctl.stop(name, kill=True)
@step('I shut down postmaster on {name:w}')
@step('I kill postmaster on {name:w}')
def stop_postgres(context, name):
return context.pctl.stop(name, postgres=True)
@step('I kill postmaster on {name:w}')
def kill_postgres(context, name):
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}')
def add_table(context, table_name, pg_name):
# parse the configuration file and get the port
try:
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:
assert False, "Error creating table {0} on {1}: {2}".format(table_name, pg_name, e)
@@ -65,7 +53,9 @@ def add_table(context, table_name, pg_name):
def toggle_wal_replay(context, action, pg_name):
# pause or resume the wal replay process
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:
assert False, "Error during {0} wal recovery on {1}: {2}".format(action, pg_name, e)
@@ -115,18 +105,11 @@ def replication_works(context, primary, replica, time_limit):
context.execute_steps(u"""
When I add the table test_{0} to {1}
Then table test_{0} is present on {2} after {3} seconds
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
""".format(int(time()), primary, replica, time_limit))
@step('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
def check_patroni_log(context, message_list, level, node, timeout):
timeout *= context.timeout_multiplier
message_list = json.loads(message_list)
for _ in range(int(timeout)):
messsages_of_level = context.pctl.read_patroni_log(node, level)
if any(any(message in line for line in messsages_of_level) for message in message_list):
break
sleep(1)
else:
assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds"
@then('there is a "{message}" {level:w} in the {node} patroni log')
def check_patroni_log(context, message, level, node):
messsages_of_level = context.pctl.read_patroni_log(node, level)
assert any(message in line for line in messsages_of_level), \
"There was no {0} {1} in the {2} patroni log".format(message, level, node)
+1 -1
View File
@@ -28,7 +28,7 @@ def check_member(context, name, key, value, time_limit):
while time.time() < max_time:
try:
response = json.loads(context.dcs_ctl.query(name))
dcs_value = str(response.get(key))
dcs_value = response.get(key)
if dcs_value == value:
return
except Exception:
+7 -13
View File
@@ -115,21 +115,15 @@ def count_rows(context, name):
assert rows == context.insert_counter, "Distributed table doesn't have expected amount of rows"
@step("there is a transaction in progress on {name:w} changing pg_dist_node after {time_limit:d} seconds")
def check_transaction(context, name, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while time.time() < max_time:
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'")
if cur.rowcount == 1:
context.xact_start = cur.fetchone()[0]
return
time.sleep(1)
assert False, f"There is no idle in transaction on {name} updating pg_dist_node after {time_limit} seconds"
@step("There is a transaction in progress on {name:w} changing pg_dist_node")
def check_transaction(context, name):
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'")
assert cur.rowcount == 1, "There is no idle in transaction updating pg_dist_node"
context.xact_start = cur.fetchone()[0]
@step("a transaction finishes in {timeout:d} seconds")
def check_transaction_timeout(context, timeout):
assert (datetime.now(tzutc) - context.xact_start).seconds >= timeout, \
assert (datetime.now(tzutc) - context.xact_start).seconds > timeout, \
"a transaction finished earlier than in {0} seconds".format(timeout)
-6
View File
@@ -128,12 +128,6 @@ def scheduled_restart(context, url, in_seconds, data):
context.execute_steps(u"""Given I issue a POST request to {0}/restart with {1}""".format(url, json.dumps(data)))
@step('I {action:w} {tag:w} tag in {pg_name:w} config')
def add_bool_tag_to_config(context, action, tag, pg_name):
value = action == 'set'
context.pctl.add_tag_to_config(pg_name, tag, value)
@step('I add tag {tag:w} {value:w} to {pg_name:w} config')
def add_tag_to_config(context, tag, value, pg_name):
context.pctl.add_tag_to_config(pg_name, tag, value)
+17 -62
View File
@@ -1,4 +1,3 @@
import json
import time
from behave import step, then
@@ -16,30 +15,21 @@ def create_logical_replication_slot(context, slot_name, pg_name, plugin):
assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
@step('{pg_name:w} has a logical replication slot named {slot_name}'
' with the {plugin:w} plugin after {time_limit:d} seconds')
@then('{pg_name:w} has a logical replication slot named {slot_name}'
' with the {plugin:w} plugin after {time_limit:d} seconds')
def has_logical_replication_slot(context, pg_name, slot_name, plugin, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while time.time() < max_time:
try:
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
f" WHERE slot_name = '{slot_name}'")).fetchone()
if row:
assert row[0] == "logical", f"Replication slot {slot_name} isn't a logical but {row[0]}"
assert row[1] == plugin, f"Replication slot {slot_name} using plugin {row[1]} rather than {plugin}"
return
except Exception:
pass
time.sleep(1)
assert False, f"Error looking for slot {slot_name} on {pg_name} with plugin {plugin}"
@then('{pg_name:w} has a logical replication slot named {slot_name} with the {plugin:w} plugin')
def has_logical_replication_slot(context, pg_name, slot_name, plugin):
try:
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
assert row, "Couldn't find replication slot named {0}".format(slot_name)
assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".format(slot_name)
assert row[1] == plugin, ("Found replication slot named {0} but was using plugin "
"{1} rather than {2}").format(slot_name, row[1], plugin)
except pg.Error:
assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
@step('{pg_name:w} does not have a replication slot named {slot_name:w}')
@then('{pg_name:w} does not have a replication slot named {slot_name:w}')
def does_not_have_replication_slot(context, pg_name, slot_name):
@then('{pg_name:w} does not have a logical replication slot named {slot_name}')
def does_not_have_logical_replication_slot(context, pg_name, slot_name):
try:
row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots"
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
@@ -48,14 +38,13 @@ def does_not_have_replication_slot(context, pg_name, slot_name):
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
@step('{slot_type:w} slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
def slots_in_sync(context, slot_type, slot_name, pg_name1, pg_name2, time_limit):
@step('Logical slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
column = 'confirmed_flush_lsn' if slot_type.lower() == 'logical' else 'restart_lsn'
query = f"SELECT {column} FROM pg_replication_slots WHERE slot_name = '{slot_name}'"
while time.time() < max_time:
try:
query = "SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = '{0}'".format(slot_name)
slot1 = context.pctl.query(pg_name1, query).fetchone()
slot2 = context.pctl.query(pg_name2, query).fetchone()
if slot1[0] == slot2[0]:
@@ -63,43 +52,9 @@ def slots_in_sync(context, slot_type, slot_name, pg_name1, pg_name2, time_limit)
except Exception:
pass
time.sleep(1)
assert False, \
f"{slot_type} slot {slot_name} is not in sync between {pg_name1} and {pg_name2} after {time_limit} seconds"
assert False, "Logical slot {0} is not in sync between {1} and {2}".format(slot_name, pg_name1, pg_name2)
@step('I get all changes from logical slot {slot_name:w} on {pg_name:w}')
def logical_slot_get_changes(context, slot_name, pg_name):
context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name))
@step('I get all changes from physical slot {slot_name:w} on {pg_name:w}')
def physical_slot_get_changes(context, slot_name, pg_name):
context.pctl.query(pg_name, f"SELECT * FROM pg_replication_slot_advance('{slot_name}', pg_current_wal_lsn())")
@step('{pg_name:w} has a physical replication slot named {slot_name} after {time_limit:d} seconds')
def has_physical_replication_slot(context, pg_name, slot_name, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
query = f"SELECT * FROM pg_catalog.pg_replication_slots WHERE slot_type = 'physical' AND slot_name = '{slot_name}'"
while time.time() < max_time:
try:
row = context.pctl.query(pg_name, query).fetchone()
if row:
return
except Exception:
pass
time.sleep(1)
assert False, f"Physical slot {slot_name} doesn't exist after {time_limit} seconds"
@step('"{name}" key in DCS has {subkey:w} in {key:w}')
def dcs_key_contains(context, name, subkey, key):
response = json.loads(context.dcs_ctl.query(name))
assert key in response and subkey in response[key], f"{name} key in DCS doesn't have {subkey} in {key}"
@step('"{name}" key in DCS does not have {subkey:w} in {key:w}')
def dcs_key_does_not_contain(context, name, subkey, key):
response = json.loads(context.dcs_ctl.query(name))
assert key not in response or subkey not in response[key], f"{name} key in DCS has {subkey} in {key}"
+3 -2
View File
@@ -15,7 +15,9 @@ def start_patroni(context, name, cluster_name):
"scope": cluster_name,
"postgresql": {
"callbacks": callbacks(context, name),
"backup_restore": context.pctl.backup_restore_config()
"backup_restore": {
"command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir="
+ os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))}
}
})
@@ -32,7 +34,6 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
"ttl": 20,
"loop_wait": 2,
"retry_timeout": 5,
"synchronous_mode": True, # should be completely ignored
"standby_cluster": {
"host": "localhost",
"port": port,
+3 -3
View File
@@ -1,4 +1,4 @@
FROM postgres:16
FROM postgres:15
LABEL maintainer="Alexander Kukushkin <[email protected]>"
RUN export DEBIAN_FRONTEND=noninteractive \
@@ -9,8 +9,8 @@ RUN export DEBIAN_FRONTEND=noninteractive \
| xargs apt-get install -y vim-tiny curl jq locales git python3-pip python3-wheel \
## 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 \
&& pip3 install --break-system-packages setuptools \
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& pip3 install setuptools \
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& PGHOME=/home/postgres \
&& mkdir -p $PGHOME \
&& chown postgres $PGHOME \
+7 -22
View File
@@ -1,4 +1,4 @@
FROM postgres:16
FROM postgres:15
LABEL maintainer="Alexander Kukushkin <[email protected]>"
RUN export DEBIAN_FRONTEND=noninteractive \
@@ -10,24 +10,12 @@ RUN export DEBIAN_FRONTEND=noninteractive \
| xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel lsb-release \
## Make sure we have a en_US.UTF-8 locale available
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
apt-get install -y postgresql-server-dev-16 \
gcc make autoconf \
libc6-dev flex libcurl4-gnutls-dev \
libicu-dev libkrb5-dev liblz4-dev \
libpam0g-dev libreadline-dev libselinux1-dev\
libssl-dev libxslt1-dev libzstd-dev uuid-dev \
&& git clone -b "main" https://github.com/citusdata/citus.git \
&& MAKEFLAGS="-j $(grep -c ^processor /proc/cpuinfo)" \
&& cd citus && ./configure && make install && cd ../ && rm -rf /citus; \
else \
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-16-citus-12.1; \
fi \
&& pip3 install --break-system-packages setuptools \
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
&& apt-get update -y \
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3 \
&& pip3 install setuptools \
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
&& PGHOME=/home/postgres \
&& mkdir -p $PGHOME \
&& chown postgres $PGHOME \
@@ -38,9 +26,6 @@ RUN export DEBIAN_FRONTEND=noninteractive \
&& chmod 664 /etc/passwd \
# Clean up
&& apt-get remove -y git python3-pip python3-wheel \
postgresql-server-dev-16 gcc make autoconf \
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* /root/.cache
+4 -8
View File
@@ -13,17 +13,13 @@ def hiddenimports():
sys.path.pop(0)
def resources():
import os
res_dir = 'patroni/postgresql/available_parameters/'
exts = set(f.split('.')[-1] for f in os.listdir(res_dir))
return [(res_dir + '*.' + e, res_dir) for e in exts if e.lower() in {'yml', 'yaml'}]
a = Analysis(['patroni/__main__.py'],
pathex=[],
binaries=None,
datas=resources(),
datas=[
('patroni/postgresql/available_parameters/*.yml', 'patroni/postgresql/available_parameters'),
('patroni/postgresql/available_parameters/*.yaml', 'patroni/postgresql/available_parameters'),
],
hiddenimports=hiddenimports(),
hookspath=[],
runtime_hooks=[],
+53 -7
View File
@@ -3,14 +3,23 @@
:var PATRONI_ENV_PREFIX: prefix for Patroni related configuration environment variables.
:var KUBERNETES_ENV_PREFIX: prefix for Kubernetes related configuration environment variables.
:var MIN_PSYCOPG2: minimum version of :mod:`psycopg2` required by Patroni to work.
:var MIN_PSYCOPG3: minimum version of :mod:`psycopg` required by Patroni to work.
"""
from typing import Iterator, Tuple
import sys
from typing import Any, Callable, Iterator, Tuple
PATRONI_ENV_PREFIX = 'PATRONI_'
KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
MIN_PSYCOPG2 = (2, 5, 4)
MIN_PSYCOPG3 = (3, 0, 0)
def fatal(string: str, *args: Any) -> None:
"""Write a fatal message to stderr and exit with code ``1``.
:param string: message to be written before exiting.
"""
sys.exit('FATAL: ' + string.format(*args))
def parse_version(version: str) -> Tuple[int, ...]:
@@ -19,25 +28,25 @@ def parse_version(version: str) -> Tuple[int, ...]:
.. note::
Designed for easy comparison of software versions in Python.
:param version: human-readable software version, e.g. ``2.5.4.dev1 (dt dec pq3 ext lo64)``.
:param version: human-readable software version, e.g. ``2.5.4``.
:returns: tuple of *version* parts, each part as an integer.
:Example:
>>> parse_version('2.5.4.dev1 (dt dec pq3 ext lo64)')
>>> parse_version('2.5.4')
(2, 5, 4)
"""
def _parse_version(version: str) -> Iterator[int]:
"""Yield each part of a human-readable version string as an integer.
:param version: human-readable software version, e.g. ``2.5.4.dev1``.
:param version: human-readable software version, e.g. ``2.5.4``.
:yields: each part of *version* as an integer.
:Example:
>>> tuple(_parse_version('2.5.4.dev1'))
>>> tuple(_parse_version('2.5.4'))
(2, 5, 4)
"""
for e in version.split('.'):
@@ -46,3 +55,40 @@ def parse_version(version: str) -> Tuple[int, ...]:
except ValueError:
break
return tuple(_parse_version(version.split(' ')[0]))
def check_psycopg(_min_psycopg2: Tuple[int, ...] = MIN_PSYCOPG2,
_parse_version: Callable[[str], Tuple[int, ...]] = parse_version) -> None:
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
.. note::
We pass ``MIN_PSYCOPG2`` and :func:`parse_version` as arguments to simplify usage of :func:`check_psycopg` from
the ``setup.py``.
.. note::
Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible.
If nothing meeting the requirements is found, then exit with a fatal message.
:param _min_psycopg2: minimum required version in case :mod:`psycopg2` is chosen.
:param _parse_version: function used to parse :mod:`psycopg2`/:mod:`psycopg` version into a comparable object.
"""
min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
# try psycopg2
try:
from psycopg2 import __version__
if _parse_version(__version__) >= _min_psycopg2:
return
version_str = __version__.split(' ')[0]
except ImportError:
version_str = None
# try psycopg3
try:
from psycopg import __version__
except ImportError:
error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
if version_str is not None:
error += ', but only psycopg2=={0} is available'.format(version_str)
fatal(error)
+11 -53
View File
@@ -10,9 +10,8 @@ import sys
import time
from argparse import Namespace
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from typing import Any, Dict, Optional, TYPE_CHECKING
from patroni import MIN_PSYCOPG2, MIN_PSYCOPG3, parse_version
from patroni.daemon import AbstractPatroniDaemon, abstract_main, get_base_arg_parser
from patroni.tags import Tags
@@ -68,7 +67,7 @@ class Patroni(AbstractPatroniDaemon, Tags):
self.watchdog = Watchdog(self.config)
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.ha = Ha(self)
@@ -116,14 +115,11 @@ class Patroni(AbstractPatroniDaemon, Tags):
if not isinstance(member, Member):
return
try:
# Silence annoying WARNING: Retrying (...) messages when Patroni is quickly restarted.
# At this moment we don't have custom log levels configured and hence shouldn't lose anything useful.
self.logger.update_loggers({'urllib3.connectionpool': 'ERROR'})
_ = self.request(member, endpoint="/liveness", timeout=3)
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
sys.exit(1)
except Exception:
self.logger.update_loggers({})
return
def _get_tags(self) -> Dict[str, Any]:
"""Get tags configured for this node, if any.
@@ -229,6 +225,11 @@ def patroni_main(configfile: str) -> None:
:param configfile: path to Patroni configuration file.
"""
from multiprocessing import freeze_support
# Windows executables created by PyInstaller are frozen, thus we need to enable frozen support for
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
freeze_support()
abstract_main(Patroni, configfile)
@@ -280,45 +281,6 @@ def process_arguments() -> Namespace:
return args
def check_psycopg() -> None:
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
.. note::
Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible.
If nothing meeting the requirements is found, then exit with a fatal message.
"""
min_psycopg2_str = '.'.join(map(str, MIN_PSYCOPG2))
min_psycopg3_str = '.'.join(map(str, MIN_PSYCOPG3))
available_versions: List[str] = []
# try psycopg2
try:
from psycopg2 import __version__
if parse_version(__version__) >= MIN_PSYCOPG2:
return
available_versions.append('psycopg2=={0}'.format(__version__.split(' ')[0]))
except ImportError:
logger.debug('psycopg2 module is not available')
# try psycopg3
try:
from psycopg import __version__
if parse_version(__version__) >= MIN_PSYCOPG3:
return
available_versions.append('psycopg=={0}'.format(__version__.split(' ')[0]))
except ImportError:
logger.debug('psycopg module is not available')
error = f'FATAL: Patroni requires psycopg2>={min_psycopg2_str}, psycopg2-binary, or psycopg>={min_psycopg3_str}'
if available_versions:
error += ', but only {0} {1} available'.format(
' and '.join(available_versions),
'is' if len(available_versions) == 1 else 'are')
sys.exit(error)
def main() -> None:
"""Main entrypoint of :mod:`patroni.__main__`.
@@ -330,16 +292,12 @@ def main() -> None:
``patroni`` daemon as another process. In that case relevant signals received by the main process and forwarded
to ``patroni`` daemon process.
"""
from multiprocessing import freeze_support
# Executables created by PyInstaller are frozen, thus we need to enable frozen support for
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
freeze_support()
check_psycopg()
from patroni import check_psycopg
args = process_arguments()
check_psycopg()
if os.getpid() != 1:
return patroni_main(args.configfile)
+38 -141
View File
@@ -12,7 +12,6 @@ import json
import logging
import time
import traceback
import dateutil.parser
import datetime
import os
import socket
@@ -26,18 +25,18 @@ from urllib.parse import urlparse, parse_qs
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 .dcs import Cluster
from .exceptions import PostgresConnectionException, PostgresException
from .manual_failover import ManualFailover
from .postgresql.misc import postgres_version_to_int
from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \
RetryFailedError, parse_int, split_host_port, tzutc, uri, cluster_as_json
RetryFailedError, parse_int, parse_schedule, split_host_port, tzutc, uri, cluster_as_json
logger = logging.getLogger(__name__)
def check_access(func: Callable[..., None]) -> Callable[..., None]:
def check_access(func: Callable[['RestApiHandler'], None]) -> Callable[..., None]:
"""Check the source ip, authorization header, or client certificates.
.. note::
@@ -103,7 +102,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
if TYPE_CHECKING: # pragma: no cover
assert isinstance(server, RestApiServer)
super(RestApiHandler, self).__init__(request, client_address, server)
self.server: 'RestApiServer' = server # pyright: ignore [reportIncompatibleVariableOverride]
self.server: 'RestApiServer' = server
self.__start_time: float = 0.0
self.path_query: Dict[str, List[str]] = {}
@@ -180,8 +179,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
* ``tags``: tags that were set through Patroni configuration merged with dynamically applied tags;
* ``database_system_identifier``: ``Database system identifier`` from ``pg_controldata`` output;
* ``pending_restart``: ``True`` if PostgreSQL is pending to be restarted;
* ``pending_restart_reason``: dictionary where each key is the parameter that caused "pending restart" flag
to be set and the value is a dictionary with the old and the new value.
* ``scheduled_restart``: a dictionary with a single key ``schedule``, which is the timestamp for the
scheduled restart;
* ``watchdog_failed``: ``True`` if watchdog device is unhealthy;
@@ -198,9 +195,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
response['tags'] = tags
if 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_reason'] = dict(patroni.postgresql.pending_restart_reason)
response['patroni'] = {
'version': patroni.version,
'scope': patroni.postgresql.scope,
@@ -293,7 +289,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
patroni = self.server.patroni
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
replayed_location = response.get('xlog', {}).get('replayed_location', 0)
@@ -311,7 +307,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503
elif patroni.ha.is_leader():
leader_status_code = 200
if config.is_standby_cluster:
if global_config.is_standby_cluster:
primary_status_code = replica_status_code = 503
standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503
else:
@@ -454,9 +450,10 @@ class RestApiHandler(BaseHTTPRequestHandler):
Write an HTTP response with JSON content based on the output of :func:`~patroni.utils.cluster_as_json`, with
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(True)
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
self._write_json_response(200, response)
@@ -637,7 +634,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
metrics.append("# TYPE patroni_pending_restart gauge")
metrics.append("patroni_pending_restart{0} {1}"
.format(labels, int(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("# TYPE patroni_is_paused gauge")
@@ -692,7 +689,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
"""
request = self._read_json_content()
if request:
cluster = self.server.patroni.dcs.get_cluster()
cluster = self.server.patroni.dcs.get_cluster(True)
if not (cluster.config and cluster.config.modify_version):
return self.send_error(503)
data = cluster.config.data.copy()
@@ -779,44 +776,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.server.patroni.api_sigterm()
self.write_response(202, 'shutdown scheduled')
@staticmethod
def parse_schedule(schedule: str,
action: str) -> Tuple[Union[int, None], Union[str, None], Union[datetime.datetime, None]]:
"""Parse the given *schedule* and validate it.
:param schedule: a string representing a timestamp, e.g. ``2023-04-14T20:27:00+00:00``.
:param action: the action to be scheduled (``restart``, ``switchover``, or ``failover``).
:returns: a tuple composed of 3 items:
* Suggested HTTP status code for a response:
* ``None``: if no issue was faced while parsing, leaving it up to the caller to decide the status; or
* ``400``: if no timezone information could be found in *schedule*; or
* ``422``: if *schedule* is invalid -- in the past or not parsable.
* An error message, if any error is faced, otherwise ``None``;
* Parsed *schedule*, if able to parse, otherwise ``None``.
"""
error = None
scheduled_at = None
try:
scheduled_at = dateutil.parser.parse(schedule)
if scheduled_at.tzinfo is None:
error = 'Timezone information is mandatory for the scheduled {0}'.format(action)
status_code = 400
elif scheduled_at < datetime.datetime.now(tzutc):
error = 'Cannot schedule {0} in the past'.format(action)
status_code = 422
else:
status_code = None
except (ValueError, TypeError):
logger.exception('Invalid scheduled %s time: %s', action, schedule)
error = 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601'
status_code = 422
return status_code, error, scheduled_at
@check_access
def do_POST_restart(self) -> None:
"""Handle a ``POST`` request to ``/restart`` path.
@@ -866,15 +825,15 @@ class RestApiHandler(BaseHTTPRequestHandler):
if 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")
return
for k in request:
if k == 'schedule':
(_, data, request[k]) = self.parse_schedule(request[k], "restart")
if _:
status_code = _
parse_result, request[k] = parse_schedule(request[k])
if parse_result:
data, status_code = parse_result.value[0], parse_result.value[1]
break
elif k == 'role':
if request[k] not in ('master', 'primary', 'replica'):
@@ -1024,39 +983,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
logger.debug('Exception occurred during polling %s result: %s', action, e)
return 503, action.title() + ' status unknown'
def is_failover_possible(self, cluster: Cluster, leader: Optional[str], candidate: Optional[str],
action: str) -> Optional[str]:
"""Checks whether there are nodes that could take over after demoting the primary.
:param cluster: the Patroni cluster.
:param leader: name of the current Patroni leader.
:param candidate: name of the Patroni node to be promoted.
:param action: the action to be performed (``switchover`` or ``failover``).
:returns: a string with the error message or ``None`` if good nodes are found.
"""
is_synchronous_mode = global_config.from_cluster(cluster).is_synchronous_mode
if leader and (not cluster.leader or cluster.leader.name != leader):
return 'leader name does not match'
if candidate:
if action == 'switchover' and is_synchronous_mode and not cluster.sync.matches(candidate):
return 'candidate name does not match with sync_standby'
members = [m for m in cluster.members if m.name == candidate]
if not members:
return 'candidate does not exists'
elif is_synchronous_mode:
members = [m for m in cluster.members if cluster.sync.matches(m.name)]
if not members:
return action + ' is not possible: can not find sync_standby'
else:
members = [m for m in cluster.members if not cluster.leader or m.name != cluster.leader.name and m.api_url]
if not members:
return action + ' is not possible: cluster does not have members except leader'
for st in self.server.patroni.ha.fetch_nodes_statuses(members):
if st.failover_limitation() is None:
return None
return action + ' is not possible: no good candidates have been found'
@check_access
def do_POST_failover(self, action: str = 'failover') -> None:
"""Handle a ``POST`` request to ``/failover`` path.
@@ -1085,7 +1011,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
:param action: the action to be performed (``switchover`` or ``failover``).
"""
request = self._read_json_content()
(status_code, data) = (400, '')
if not request:
return
@@ -1093,38 +1018,20 @@ class RestApiHandler(BaseHTTPRequestHandler):
candidate = request.get('candidate') or request.get('member')
scheduled_at = request.get('scheduled_at')
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",
action, leader, candidate, scheduled_at)
if action == 'failover' and not candidate:
data = 'Failover could be performed only to a specific candidate'
elif action == 'switchover' and not leader:
data = 'Switchover could be performed only from a specific leader'
manual_failover = ManualFailover(action, cluster, leader, candidate, scheduled_at,
global_config.is_paused, global_config.is_synchronous_mode,
self.server.patroni)
data, status_code = manual_failover.run_precheck().value
if not data and scheduled_at:
if action == 'failover':
data = "Failover can't be scheduled"
elif config.is_paused:
data = "Can't schedule switchover in the paused state"
else:
(status_code, data, scheduled_at) = self.parse_schedule(scheduled_at, action)
if not data and config.is_paused and not candidate:
data = 'Switchover is possible only to a specific candidate in a paused state'
if action == 'failover' and leader:
logger.warning('received failover request with leader specifed - performing switchover instead')
action = 'switchover'
if not data and leader == candidate:
data = 'Switchover target and source are the same'
if not data and not scheduled_at:
data = self.is_failover_possible(cluster, leader, candidate, action)
if data:
status_code = 412
parse_result, scheduled_at = manual_failover.parse_scheduled()
if parse_result:
data, status_code = parse_result.value[0], parse_result.value[1]
if not data:
if self.server.patroni.dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at):
@@ -1138,12 +1045,10 @@ class RestApiHandler(BaseHTTPRequestHandler):
else:
data = 'failed to write failover key into DCS'
status_code = 503
# pyright thinks ``status_code`` can be ``None`` because ``parse_schedule`` call may return ``None``. However,
# if that's the case, ``status_code`` will be overwritten somewhere between ``parse_schedule`` and
# ``write_response`` calls.
if TYPE_CHECKING: # pragma: no cover
assert isinstance(status_code, int)
self.write_response(status_code, data)
status_code = status_code or 400
self.write_response(status_code, data.format(action=action, leader=leader, candidate=candidate,
cluster_name=self.server.patroni.postgresql.scope))
def do_POST_switchover(self) -> None:
"""Handle a ``POST`` request to ``/switchover`` path.
@@ -1156,16 +1061,8 @@ class RestApiHandler(BaseHTTPRequestHandler):
def do_POST_citus(self) -> None:
"""Handle a ``POST`` request to ``/citus`` path.
.. note::
We keep this entrypoint for backward compatibility and simply dispatch the request to :meth:`do_POST_mpp`.
"""
self.do_POST_mpp()
def do_POST_mpp(self) -> None:
"""Handle a ``POST`` request to ``/mpp`` path.
Call :func:`~patroni.postgresql.mpp.AbstractMPPHandler.handle_event` to handle the request,
then write a response with HTTP status code ``200``.
Call :func:`~patroni.postgresql.CitusHandler.handle_event` to handle the request, then write a response with
HTTP status code ``200``.
.. note::
If unable to parse the request body, then the request is silently discarded.
@@ -1175,9 +1072,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
return
patroni = self.server.patroni
if patroni.postgresql.mpp_handler.is_coordinator() and patroni.ha.is_leader():
cluster = patroni.dcs.get_cluster()
patroni.postgresql.mpp_handler.handle_event(cluster, request)
if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader():
cluster = patroni.dcs.get_cluster(True)
patroni.postgresql.citus_handler.handle_event(cluster, request)
self.write_response(200, 'OK')
def parse_request(self) -> bool:
@@ -1270,7 +1167,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
"""
postgresql = self.server.patroni.postgresql
cluster = self.server.patroni.dcs.cluster
config = global_config.from_cluster(cluster)
global_config = self.server.patroni.config.get_global_config(cluster)
try:
if postgresql.state not in ('running', 'restarting', 'starting'):
@@ -1301,10 +1198,10 @@ 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
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):
result['sync_standby'] = True
@@ -1329,7 +1226,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
state = 'unknown'
result: Dict[str, Any] = {'state': state, 'role': postgresql.role}
if config.is_paused:
if global_config.is_paused:
result['pause'] = True
if not cluster or cluster.is_unlocked():
result['cluster_unlocked'] = True
+4 -49
View File
@@ -1,10 +1,9 @@
"""Patroni custom object types somewhat like :mod:`collections` module.
Provides a case insensitive :class:`dict` and :class:`set` object types, and `EMPTY_DICT` frozen dictionary object.
Provides a case insensitive :class:`dict` and :class:`set` object types.
"""
from collections import OrderedDict
from copy import deepcopy
from typing import Any, Collection, Dict, Iterator, KeysView, Mapping, MutableMapping, MutableSet, Optional
from typing import Any, Collection, Dict, Iterator, KeysView, MutableMapping, MutableSet, Optional
class CaseInsensitiveSet(MutableSet[str]):
@@ -49,7 +48,7 @@ class CaseInsensitiveSet(MutableSet[str]):
"""
return str(set(self._values.values()))
def __contains__(self, value: object) -> bool:
def __contains__(self, value: str) -> bool:
"""Check if set contains *value*.
The check is performed case-insensitively.
@@ -58,7 +57,7 @@ class CaseInsensitiveSet(MutableSet[str]):
:returns: ``True`` if *value* is already in the set, ``False`` otherwise.
"""
return isinstance(value, str) and value.lower() in self._values
return value.lower() in self._values
def __iter__(self) -> Iterator[str]:
"""Iterate over the values in this set.
@@ -208,47 +207,3 @@ class CaseInsensitiveDict(MutableMapping[str, Any]):
"<CaseInsensitiveDict{'A': 'B', 'c': 'd'} at ..."
"""
return '<{0}{1} at {2:x}>'.format(type(self).__name__, dict(self.items()), id(self))
class _FrozenDict(Mapping[str, Any]):
"""Frozen dictionary object."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Create a new instance of :class:`_FrozenDict` with given data."""
self.__values: Dict[str, Any] = dict(*args, **kwargs)
def __iter__(self) -> Iterator[str]:
"""Iterate over keys of this dict.
:yields: each key present in the dict. Yields each key with its last case that has been stored.
"""
return iter(self.__values)
def __len__(self) -> int:
"""Get the length of this dict.
:returns: number of keys in the dict.
:Example:
>>> len(_FrozenDict())
0
"""
return len(self.__values)
def __getitem__(self, key: str) -> Any:
"""Get the value corresponding to *key*.
:returns: value corresponding to *key*.
"""
return self.__values[key]
def copy(self) -> Dict[str, Any]:
"""Create a copy of this dict.
:return: a new dict object with the same keys and values of this dict.
"""
return deepcopy(self.__values)
EMPTY_DICT = _FrozenDict()
+170 -111
View File
@@ -1,5 +1,4 @@
"""Facilities related to Patroni configuration."""
import re
import json
import logging
import os
@@ -12,12 +11,11 @@ from copy import deepcopy
from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_CHECKING
from . import PATRONI_ENV_PREFIX
from .collections import CaseInsensitiveDict, EMPTY_DICT
from .dcs import ClusterConfig
from .collections import CaseInsensitiveDict
from .dcs import ClusterConfig, Cluster
from .exceptions import ConfigParseError
from .file_perm import pg_perm
from .postgresql.config import ConfigHandler
from .validator import IntValidator
from .utils import deep_compare, parse_bool, parse_int, patch_config
logger = logging.getLogger(__name__)
@@ -55,6 +53,154 @@ def default_validator(conf: Dict[str, Any]) -> List[str]:
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."""
return self.check_mode('synchronous_mode')
@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):
"""Handle Patroni configuration.
@@ -143,9 +289,8 @@ class Config(object):
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
if validator: # patronictl uses validator=None
self._load_cache() # we don't want to load anything from local cache for ctl
self._validate_failover_tags() # irrelevant for ctl
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
self._load_cache()
self._cache_needs_saving = False
@property
@@ -254,66 +399,6 @@ class Config(object):
except Exception:
logger.error('Can not remove temporary file %s', tmpfile)
def __get_and_maybe_adjust_int_value(self, config: Dict[str, Any], param: str, min_value: int) -> int:
"""Get, validate and maybe adjust a *param* integer value from the *config* :class:`dict`.
.. note:
If the value is smaller than provided *min_value* we update the *config*.
This method may raise an exception if value isn't :class:`int` or cannot be casted to :class:`int`.
:param config: :class:`dict` object with new global configuration.
:param param: name of the configuration parameter we want to read/validate/adjust.
:param min_value: the minimum possible value that a given *param* could have.
:returns: an integer value which corresponds to a provided *param*.
"""
value = int(config.get(param, self.__DEFAULT_CONFIG[param]))
if value < min_value:
logger.warning("%s=%d can't be smaller than %d, adjusting...", param, value, min_value)
value = config[param] = min_value
return value
def _validate_and_adjust_timeouts(self, config: Dict[str, Any]) -> None:
"""Validate and adjust ``loop_wait``, ``retry_timeout``, and ``ttl`` values if necessary.
Minimum values:
* ``loop_wait``: 1 second;
* ``retry_timeout``: 3 seconds.
* ``ttl``: 20 seconds;
Maximum values:
In case if values don't fulfill the following rule, ``retry_timeout`` and ``loop_wait``
are reduced so that the rule is fulfilled:
.. code-block:: python
loop_wait + 2 * retry_timeout <= ttl
.. note:
We prefer to reduce ``loop_wait`` and will reduce ``retry_timeout`` only if ``loop_wait``
is already set to a minimal possible value.
:param config: :class:`dict` object with new global configuration.
"""
min_loop_wait = 1
loop_wait = self. __get_and_maybe_adjust_int_value(config, 'loop_wait', min_loop_wait)
retry_timeout = self. __get_and_maybe_adjust_int_value(config, 'retry_timeout', 3)
ttl = self. __get_and_maybe_adjust_int_value(config, 'ttl', 20)
if min_loop_wait + 2 * retry_timeout > ttl:
config['loop_wait'] = min_loop_wait
config['retry_timeout'] = (ttl - min_loop_wait) // 2
logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d. '
'Adjusting loop_wait from %d to %d and retry_timeout from %d to %d',
ttl, loop_wait, min_loop_wait, retry_timeout, config['retry_timeout'])
elif loop_wait + 2 * retry_timeout > ttl:
config['loop_wait'] = ttl - 2 * retry_timeout
logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d and retry_timeout=%d.'
' Adjusting loop_wait from %d to %d', ttl, retry_timeout, loop_wait, config['loop_wait'])
# configuration could be either ClusterConfig or dict
def set_dynamic_configuration(self, configuration: Union[ClusterConfig, Dict[str, Any]]) -> bool:
"""Set dynamic configuration values with given *configuration*.
@@ -331,7 +416,6 @@ class Config(object):
if not deep_compare(self._dynamic_configuration, configuration):
try:
self._validate_and_adjust_timeouts(configuration)
self.__effective_configuration = self._build_effective_configuration(configuration,
self._local_configuration)
self._dynamic_configuration = configuration
@@ -357,7 +441,6 @@ class Config(object):
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
self._local_configuration = configuration
self.__effective_configuration = new_configuration
self._validate_failover_tags()
return True
else:
logger.info('No local configuration items changed.')
@@ -404,10 +487,8 @@ class Config(object):
if name not in ConfigHandler.CMDLINE_OPTIONS:
pg_params[name] = value
elif not is_local:
validator = ConfigHandler.CMDLINE_OPTIONS[name][1]
if validator(value):
int_val = parse_int(value) if isinstance(validator, IntValidator) else None
pg_params[name] = int_val if isinstance(int_val, int) else value
if ConfigHandler.CMDLINE_OPTIONS[name][1](value):
pg_params[name] = value
else:
logger.warning("postgresql parameter %s=%s failed validation, defaulting to %s",
name, value, ConfigHandler.CMDLINE_OPTIONS[name][0])
@@ -445,14 +526,14 @@ class Config(object):
for name, value in dynamic_configuration.items():
if name == 'postgresql':
for name, value in (value or EMPTY_DICT).items():
for name, value in (value or {}).items():
if name == 'parameters':
config['postgresql'][name].update(self._process_postgresql_parameters(value))
elif name not in ('connect_address', 'proxy_address', 'listen',
'config_dir', 'data_dir', 'pgpass', 'authentication'):
config['postgresql'][name] = deepcopy(value)
elif name == 'standby_cluster':
for name, value in (value or EMPTY_DICT).items():
for name, value in (value or {}).items():
if name in self.__DEFAULT_CONFIG['standby_cluster']:
config['standby_cluster'][name] = deepcopy(value)
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
@@ -535,8 +616,8 @@ class Config(object):
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
_set_section_values('log', ['type', 'level', 'traceback_level', 'format', 'dateformat', 'static_fields',
'max_queue_size', 'dir', 'file_size', 'file_num', 'loggers'])
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
'dir', 'file_size', 'file_num', 'loggers'])
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
for binary in ('pg_ctl', 'initdb', 'pg_controldata', 'pg_basebackup', 'postgres', 'pg_isready', 'pg_rewind'):
@@ -583,12 +664,6 @@ class Config(object):
if value:
ret[first][second] = value
logformat = ret.get('log', {}).get('format')
if logformat and not re.search(r'%\(\w+\)', logformat):
logformat = _parse_list(logformat)
if logformat:
ret['log']['format'] = logformat
def _parse_dict(value: str) -> Optional[Dict[str, Any]]:
"""Parse an YAML dictionary *value* as a :class:`dict`.
@@ -604,12 +679,7 @@ class Config(object):
logger.exception('Exception when parsing dict %s', value)
return None
dict_configs = (
('restapi', ('http_extra_headers', 'https_extra_headers')),
('log', ('static_fields', 'loggers'))
)
for first, params in dict_configs:
for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
for second in params:
value = ret.get(first, {}).pop(second, None)
if value:
@@ -656,7 +726,7 @@ class Config(object):
'SERVICE_TAGS', 'NAMESPACE', 'CONTEXT', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL',
'POD_IP', 'PORTS', 'LABELS', 'BYPASS_API_SERVICE', 'RETRIABLE_HTTP_CODES', 'KEY_PASSWORD',
'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)
if name == 'CITUS':
if suffix == 'GROUP':
@@ -667,7 +737,7 @@ class Config(object):
value = value and parse_int(value)
elif suffix in ('HOSTS', 'PORTS', 'CHECKS', 'SERVICE_TAGS', 'RETRIABLE_HTTP_CODES'):
value = value and _parse_list(value)
elif suffix in ('LABELS', 'SET_ACLS', 'AUTH_DATA'):
elif suffix in ('LABELS', 'SET_ACLS'):
value = _parse_dict(value)
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
value = parse_bool(value)
@@ -814,25 +884,14 @@ class Config(object):
"""
return deepcopy(self.__effective_configuration)
def _validate_failover_tags(self) -> None:
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
def get_global_config(self, cluster: Optional[Cluster]) -> GlobalConfig:
"""Instantiate :class:`GlobalConfig` based on input.
.. note::
To preserve sanity (and backwards compatibility) the ``nofailover`` tag will still exist. A contradictory
configuration is one where ``nofailover`` is ``True`` but ``failover_priority > 0``, or where
``nofailover`` is ``False``, but ``failover_priority <= 0``. Essentially, ``nofailover`` and
``failover_priority`` are communicating different things.
This checks for this edge case (which is a misconfiguration on the part of the user) and warns them.
The behaviour is as if ``failover_priority`` were not provided (i.e ``nofailover`` is the
bedrock source of truth)
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.
"""
tags = self.get('tags', {})
if 'nofailover' not in tags:
return
nofailover_tag = tags.get('nofailover')
failover_priority_tag = parse_int(tags.get('failover_priority'))
if failover_priority_tag is not None \
and (bool(nofailover_tag) is True and failover_priority_tag > 0
or bool(nofailover_tag) is False and failover_priority_tag <= 0):
logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. '
'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag)
return get_global_config(cluster, self._dynamic_configuration)
+57 -110
View File
@@ -9,16 +9,14 @@ import yaml
from getpass import getuser, getpass
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, TYPE_CHECKING, Union
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union
if TYPE_CHECKING: # pragma: no cover
from psycopg import Cursor
from psycopg2 import cursor
from . import psycopg
from .collections import EMPTY_DICT
from .config import Config
from .exceptions import PatroniException
from .log import PatroniLogger
from .postgresql.config import ConfigHandler, parse_dsn
from .postgresql.misc import postgres_major_version_to_int
from .utils import get_major_version, parse_bool, patch_config, read_stripped
@@ -40,7 +38,7 @@ _AUTH_ALLOWED_PARAMETERS_MAPPING = {
'gssencmode': 'PGGSSENCMODE',
'channel_binding': 'PGCHANNELBINDING'
}
NO_VALUE_MSG = '#FIXME'
_NO_VALUE_MSG = '#FIXME'
def get_address() -> Tuple[str, str]:
@@ -52,7 +50,7 @@ def get_address() -> Tuple[str, str]:
:returns: tuple consisting of the hostname returned by :func:`~socket.gethostname`
and the first element in the sorted list of the addresses returned by :func:`~socket.getaddrinfo`.
Sorting guarantees it will prefer IPv4.
If an exception occured, hostname and ip values are equal to :data:`~patroni.config_generator.NO_VALUE_MSG`.
If an exception occured, hostname and ip values are equal to :data:`~patroni.config_generator._NO_VALUE_MSG`.
"""
hostname = None
try:
@@ -61,7 +59,7 @@ def get_address() -> Tuple[str, str]:
key=lambda x: x[0])[0][4][0]
except Exception as err:
logging.warning('Failed to obtain address: %r', err)
return NO_VALUE_MSG, NO_VALUE_MSG
return _NO_VALUE_MSG, _NO_VALUE_MSG
class AbstractConfigGenerator(abc.ABC):
@@ -90,44 +88,30 @@ class AbstractConfigGenerator(abc.ABC):
"""Generate a template config for further extension (e.g. in the inherited classes).
:returns: dictionary with the values gathered from Patroni env, hopefully defined hostname and ip address
(otherwise set to :data:`~patroni.config_generator.NO_VALUE_MSG`), and some sane defaults.
(otherwise set to :data:`~patroni.config_generator._NO_VALUE_MSG`), and some sane defaults.
"""
template_config: Dict[str, Any] = {
'scope': NO_VALUE_MSG,
'scope': _NO_VALUE_MSG,
'name': cls._HOSTNAME,
'restapi': {
'connect_address': cls._IP + ':8008',
'listen': cls._IP + ':8008'
},
'log': {
'type': PatroniLogger.DEFAULT_TYPE,
'level': PatroniLogger.DEFAULT_LEVEL,
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
'format': PatroniLogger.DEFAULT_FORMAT,
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
},
'postgresql': {
'data_dir': NO_VALUE_MSG,
'connect_address': cls._IP + ':5432',
'listen': cls._IP + ':5432',
'data_dir': _NO_VALUE_MSG,
'connect_address': _NO_VALUE_MSG + ':5432',
'listen': _NO_VALUE_MSG + ':5432',
'bin_dir': '',
'authentication': {
'superuser': {
'username': 'postgres',
'password': NO_VALUE_MSG
'password': _NO_VALUE_MSG
},
'replication': {
'username': 'replicator',
'password': NO_VALUE_MSG
'password': _NO_VALUE_MSG
}
}
},
'tags': {
'failover_priority': 1,
'noloadbalance': False,
'clonefrom': True,
'nosync': False,
'nostream': False,
'restapi': {
'connect_address': cls._IP + ':8008',
'listen': cls._IP + ':8008'
}
}
@@ -146,72 +130,6 @@ class AbstractConfigGenerator(abc.ABC):
def generate(self) -> None:
"""Generate config and store in :attr:`~AbstractConfigGenerator.config`."""
@staticmethod
def _format_block(block: Any, line_prefix: str = '') -> str:
"""Format a single YAML block.
.. note::
Optionally the formatted block could be indented with the *line_prefix*
:param block: the object that should be formatted to YAML.
:param line_prefix: is used for indentation.
:returns: a formatted and indented *block*.
"""
return line_prefix + yaml.safe_dump(block, default_flow_style=False, line_break='\n',
allow_unicode=True, indent=2).strip().replace('\n', '\n' + line_prefix)
def _format_config_section(self, section_name: str) -> Iterator[str]:
"""Format and yield as single section of the current :attr:`~AbstractConfigGenerator.config`.
.. note::
If the section is a :class:`dict` object we put an empty line before it.
:param section_name: a section name in the :attr:`~AbstractConfigGenerator.config`.
:yields: a formatted section in case if it exists in the :attr:`~AbstractConfigGenerator.config`.
"""
if section_name in self.config:
if isinstance(self.config[section_name], dict):
yield ''
yield self._format_block({section_name: self.config[section_name]})
def _format_config(self) -> Iterator[str]:
"""Format current :attr:`~AbstractConfigGenerator.config` and enrich it with some comments.
:yields: formatted lines or blocks that represent a text output of the YAML document.
"""
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl', 'citus',
'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'):
yield from self._format_config_section(name)
if 'bootstrap' in self.config:
yield '\n# The bootstrap configuration. Works only when the cluster is not yet initialized.'
yield '# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!'
yield 'bootstrap:'
if 'dcs' in self.config['bootstrap']:
yield ' # This section will be written into <dcs>:/<namespace>/<scope>/config after initializing'
yield ' # new cluster and all other cluster members will use it as a `global configuration`.'
yield ' # WARNING! If you want to change any of the parameters that were set up'
yield ' # via `bootstrap.dcs` section, please use `patronictl edit-config`!'
yield ' dcs:'
for name in ('loop_wait', 'retry_timeout', 'ttl'):
if name in self.config['bootstrap']['dcs']:
yield self._format_block({name: self.config['bootstrap']['dcs'].pop(name)}, ' ')
for name, value in self.config['bootstrap']['dcs'].items():
yield self._format_block({name: value}, ' ')
for name in ('postgresql', 'watchdog', 'tags'):
yield from self._format_config_section(name)
def _write_config_to_fd(self, fd: TextIO) -> None:
"""Format and write current :attr:`~AbstractConfigGenerator.config` to provided file descriptor.
:param fd: where to write the config file. Could be ``sys.stdout`` or the real file.
"""
fd.write('\n'.join(self._format_config()))
def write_config(self) -> None:
"""Write current :attr:`~AbstractConfigGenerator.config` to the output file if provided, to stdout otherwise."""
if self.output_file:
@@ -219,9 +137,9 @@ class AbstractConfigGenerator(abc.ABC):
if dir_path and not os.path.isdir(dir_path):
os.makedirs(dir_path)
with open(self.output_file, 'w', encoding='UTF-8') as output_file:
self._write_config_to_fd(output_file)
yaml.safe_dump(self.config, output_file, default_flow_style=False, allow_unicode=True)
else:
self._write_config_to_fd(sys.stdout)
yaml.safe_dump(self.config, sys.stdout, default_flow_style=False, allow_unicode=True)
class SampleConfigGenerator(AbstractConfigGenerator):
@@ -245,8 +163,7 @@ class SampleConfigGenerator(AbstractConfigGenerator):
See :func:`~patroni.postgresql.misc.postgres_major_version_to_int` and
:func:`~patroni.utils.get_major_version`.
"""
postgres_bin = ((self.config.get('postgresql')
or EMPTY_DICT).get('bin_name') or EMPTY_DICT).get('postgres', 'postgres')
postgres_bin = ((self.config.get('postgresql') or {}).get('bin_name') or {}).get('postgres', 'postgres')
return postgres_major_version_to_int(get_major_version(self.config['postgresql'].get('bin_dir'), postgres_bin))
def generate(self) -> None:
@@ -265,13 +182,10 @@ class SampleConfigGenerator(AbstractConfigGenerator):
self.config['bootstrap']['dcs']['postgresql']['parameters'][wal_keep_param] = \
ConfigHandler.CMDLINE_OPTIONS[wal_keep_param][0]
wal_level = 'hot_standby' if self.pg_major < 90600 else 'replica'
self.config['bootstrap']['dcs']['postgresql']['parameters']['wal_level'] = wal_level
self.config['bootstrap']['dcs']['postgresql']['use_pg_rewind'] = True
if self.pg_major >= 110000:
self.config['postgresql']['authentication'].setdefault(
'rewind', {'username': 'rewind_user'}).setdefault('password', NO_VALUE_MSG)
'rewind', {'username': 'rewind_user'}).setdefault('password', _NO_VALUE_MSG)
class RunningClusterConfigGenerator(AbstractConfigGenerator):
@@ -373,7 +287,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
:param cur: connection cursor to use.
"""
cur.execute("SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings "
cur.execute("SELECT name, current_setting(name) FROM pg_settings "
"WHERE context <> 'internal' "
"AND source IN ('configuration file', 'command line', 'environment variable') "
"AND category <> 'Write-Ahead Log / Recovery Target' "
@@ -413,17 +327,15 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
val = self.parsed_dsn.get(conn_param, os.getenv(env_var))
if val:
su_params[conn_param] = val
patroni_env_su_username = ((self.config.get('authentication')
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('username')
patroni_env_su_pwd = ((self.config.get('authentication')
or EMPTY_DICT).get('superuser') or EMPTY_DICT).get('password')
patroni_env_su_username = ((self.config.get('authentication') or {}).get('superuser') or {}).get('username')
patroni_env_su_pwd = ((self.config.get('authentication') or {}).get('superuser') or {}).get('password')
# because we use "username" in the config for some reason
su_params['username'] = su_params.pop('user', patroni_env_su_username) or getuser()
su_params['password'] = su_params.get('password', patroni_env_su_pwd) or \
getpass('Please enter the user password:')
self.config['postgresql']['authentication'] = {
'superuser': su_params,
'replication': {'username': NO_VALUE_MSG, 'password': NO_VALUE_MSG}
'replication': {'username': _NO_VALUE_MSG, 'password': _NO_VALUE_MSG}
}
def _set_conf_files(self) -> None:
@@ -499,6 +411,41 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
def generate_config(output_file: str, sample: bool, dsn: Optional[str]) -> None:
"""Generate Patroni configuration file.
Gather all the available non-internal GUC values having configuration file, postmaster command line or environment
variable as a source and store them in the appropriate part of Patroni configuration (``postgresql.parameters`` or
``bootstrap.dcs.postgresql.parameters``). Either the provided DSN (takes precedence) or PG ENV vars will be used
for the connection. If password is not provided, it should be entered via prompt.
The created configuration contains:
* ``scope``: ``cluster_name`` GUC value or ``PATRONI_SCOPE ENV`` variable value if available.
* ``name``: ``PATRONI_NAME`` ENV variable value if set, otherwise hostname.
* ``bootstrap.dcs``: section with all the parameters (incl. the majority of PG GUCs) set to their default values
defined by Patroni and adjusted by the source instances's configuration values.
* ``postgresql.parameters``: the source instance's ``archive_command``, ``restore_command``,
``archive_cleanup_command``, ``recovery_end_command``, ``ssl_passphrase_command``, ``hba_file``, ``ident_file``,
``config_file`` GUC values.
* ``postgresql.bin_dir``: path to Postgres binaries gathered from the running instance or, if not available,
the value of ``PATRONI_POSTGRESQL_BIN_DIR`` ENV variable. Otherwise, an empty string.
* ``postgresql.datadir``: the value gathered from the corresponding PG GUC.
* ``postgresql.listen``: source instance's ``listen_addresses`` and port GUC values.
* ``postgresql.connect_address``: if possible, generated from the connection params.
* ``postgresql.authentication``:
* superuser and replication users defined (if possible, usernames are set from the respective Patroni ENV vars,
otherwise the default ``postgres`` and ``replicator`` values are used).
If not a sample config, either DSN or PG ENV vars are used to define superuser authentication parameters.
* rewind user is defined only for sample config, if PG version can be defined and PG version is >=11
(if possible, username is set from the respective Patroni ENV var).
* ``bootstrap.dcs.postgresql.use_pg_rewind`` set to ``True`` for a sample config only.
* ``postgresql.pg_hba`` defaults or the lines gathered from the source instance's ``hba_file``.
* ``postgresql.pg_ident`` the lines gathered from the source instance's ``ident_file``.
:param output_file: Full path to the configuration file to be used. If not provided, result is sent to ``stdout``.
:param sample: Optional flag. If set, no source instance will be used - generate config with some sane defaults.
:param dsn: Optional DSN string for the local instance to get GUC values from.
+228 -268
View File
File diff suppressed because it is too large Load Diff
+246 -305
View File
@@ -1,33 +1,35 @@
"""Abstract classes for Distributed Configuration Store."""
import abc
import datetime
import importlib
import inspect
import json
import logging
import os
import pkgutil
import re
import sys
import time
from collections import defaultdict
from copy import deepcopy
from random import randint
from threading import Event, Lock
from typing import Any, Callable, Collection, Dict, Iterator, List, \
NamedTuple, Optional, Tuple, Type, TYPE_CHECKING, Union
from types import ModuleType
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
import dateutil.parser
from .. import global_config
from ..dynamic_loader import iter_classes, iter_modules
from ..exceptions import PatroniFatalException
from ..utils import deep_compare, uri
from ..tags import Tags
from ..utils import parse_int
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
from ..postgresql import Postgresql
from ..postgresql.mpp import AbstractMPP
SLOT_ADVANCE_AVAILABLE_VERSION = 110000
CITUS_COORDINATOR_GROUP_ID = 0
citus_group_re = re.compile('^(0|[1-9][0-9]*)$')
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
logger = logging.getLogger(__name__)
@@ -83,11 +85,28 @@ def parse_connection_string(value: str) -> Tuple[str, Union[str, None]]:
def dcs_modules() -> List[str]:
"""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``.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
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(
@@ -101,18 +120,44 @@ def iter_dcs_classes(
: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.
: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.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
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':
"""Attempt to load a Distributed Configuration Store from known available implementations.
.. note::
Using the list of available DCS classes returned by :func:`iter_classes` attempt to dynamically
Using the list of available DCS modules returned by :func:`iter_dcs_modules` attempt to dynamically import and
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
@@ -133,13 +178,14 @@ def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
p: config[p] for p in ('namespace', 'name', 'scope', 'loop_wait',
'patronictl', 'ttl', 'retry_timeout')
if p in config})
# From citus section we only need "group" parameter, but will propagate everything just in case.
if isinstance(config.get('citus'), dict):
config[name].update(config['citus'])
return dcs_class(config[name])
from patroni.postgresql.mpp import get_mpp
return dcs_class(config[name], get_mpp(config))
available_implementations = ', '.join(sorted([n for n, _ in iter_dcs_classes()]))
raise PatroniFatalException("Can not find suitable configuration of distributed configuration store\n"
f"Available implementations: {available_implementations}")
raise PatroniFatalException(
f"Can not find suitable configuration of distributed configuration store\n"
f"Available implementations: {', '.join(sorted([n for n, _ in iter_dcs_classes()]))}")
_Version = Union[int, str]
@@ -304,11 +350,6 @@ class Member(Tags, NamedTuple('Member',
logger.debug('Failed to parse Patroni version %s', version)
return None
@property
def lsn(self) -> Optional[int]:
"""Current LSN (receive/flush/replay)."""
return parse_int(self.data.get('xlog_location'))
class RemoteMember(Member):
"""Represents a remote member (typically a primary) for a standby cluster.
@@ -410,7 +451,7 @@ class Leader(NamedTuple):
class Failover(NamedTuple):
"""Immutable object (namedtuple) representing configuration information required for failover/switchover capability.
"""Immutable object (namedtuple) which represents failover key.
:ivar version: version of the object.
:ivar leader: name of the leader. If value isn't empty we treat it as a switchover from the specified node.
@@ -506,6 +547,13 @@ class Failover(NamedTuple):
"""
return int(bool(self.leader)) + int(bool(self.candidate))
@property
def is_switchover(self) -> bool:
return bool(self.leader)
@property
def is_failover(self) -> bool:
return not self.is_switchover
class ClusterConfig(NamedTuple):
"""Immutable object (namedtuple) which represents cluster configuration.
@@ -542,6 +590,24 @@ class ClusterConfig(NamedTuple):
modify_version = 0
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):
"""Immutable object (namedtuple) which represents last observed synchronous replication state.
@@ -560,7 +626,7 @@ class SyncState(NamedTuple):
"""Factory method to parse *value* as synchronisation state information.
: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.
@@ -719,74 +785,19 @@ class TimelineHistory(NamedTuple):
return TimelineHistory(version, value, lines)
class Status(NamedTuple):
"""Immutable object (namedtuple) which represents `/status` key.
Consists of the following fields:
:ivar last_lsn: :class:`int` object containing position of last known leader LSN.
:ivar slots: state of permanent replication slots on the primary in the format: ``{"slot_name": int}``.
"""
last_lsn: int
slots: Optional[Dict[str, int]]
@staticmethod
def empty() -> 'Status':
"""Construct an empty :class:`Status` instance.
:returns: empty :class:`Status` object.
"""
return Status(0, None)
@staticmethod
def from_node(value: Union[str, Dict[str, Any], None]) -> 'Status':
"""Factory method to parse *value* as :class:`Status` object.
:param value: JSON serialized string
:returns: constructed :class:`Status` object.
"""
try:
if isinstance(value, str):
value = json.loads(value)
except Exception:
return Status.empty()
if isinstance(value, int): # legacy
return Status(value, None)
if not isinstance(value, dict):
return Status.empty()
try:
last_lsn = int(value.get('optime', ''))
except Exception:
last_lsn = 0
slots: Union[str, Dict[str, int], None] = value.get('slots')
if isinstance(slots, str):
try:
slots = json.loads(slots)
except Exception:
slots = None
if not isinstance(slots, dict):
slots = None
return Status(last_lsn, slots)
class Cluster(NamedTuple('Cluster',
[('initialize', Optional[str]),
('config', Optional[ClusterConfig]),
('leader', Optional[Leader]),
('status', Status),
('last_lsn', int),
('members', List[Member]),
('failover', Optional[Failover]),
('sync', SyncState),
('history', Optional[TimelineHistory]),
('slots', Optional[Dict[str, int]]),
('failsafe', Optional[Dict[str, str]]),
('workers', Dict[int, 'Cluster'])])):
"""Immutable object (namedtuple) which represents PostgreSQL or MPP cluster.
"""Immutable object (namedtuple) which represents PostgreSQL or Citus cluster.
.. note::
We are using an old-style attribute declaration here because otherwise it is not possible to override `__new__`
@@ -797,14 +808,16 @@ class Cluster(NamedTuple('Cluster',
:ivar initialize: shows whether this cluster has initialization key stored in DC or not.
:ivar config: global dynamic configuration, reference to `ClusterConfig` object.
:ivar leader: :class:`Leader` object which represents current leader of the cluster.
:ivar status: :class:`Status` object which represents the `/status` key.
:ivar last_lsn: :class:int object containing position of last known leader LSN.
This value is stored in the `/status` key or `/optime/leader` (legacy) key.
:ivar members: list of:class:` Member` objects, all PostgreSQL cluster members including leader
:ivar failover: reference to :class:`Failover` object.
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
:ivar history: reference to `TimelineHistory` object.
:ivar slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}.
: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
corresponding value is a :class:`Cluster` instance.
:ivar workers: dictionary of workers of the Citus cluster, optional. Each key is an :class:`int` representing
the group, and the corresponding value is a :class:`Cluster` instance.
"""
def __new__(cls, *args: Any, **kwargs: Any):
@@ -813,20 +826,10 @@ class Cluster(NamedTuple('Cluster',
kwargs['workers'] = {}
return super(Cluster, cls).__new__(cls, *args, **kwargs)
@property
def last_lsn(self) -> int:
"""Last known leader LSN."""
return self.status.last_lsn
@property
def slots(self) -> Optional[Dict[str, int]]:
"""State of permanent replication slots on the primary in the format: ``{"slot_name": int}``."""
return self.status.slots
@staticmethod
def empty() -> 'Cluster':
"""Produce an empty :class:`Cluster` instance."""
return Cluster(None, None, None, Status.empty(), [], None, SyncState.empty(), None, None, {})
return Cluster(None, None, None, 0, [], None, SyncState.empty(), None, None, None, {})
def is_empty(self):
"""Validate definition of all attributes of this :class:`Cluster` instance.
@@ -849,7 +852,7 @@ class Cluster(NamedTuple('Cluster',
>>> assert bool(cluster) is False
>>> cluster = Cluster(None, None, None, Status(0, None), [1, 2, 3], None, SyncState.empty(), None, None, {})
>>> cluster = Cluster(None, None, None, 0, [1, 2, 3], None, SyncState.empty(), None, None, None, {})
>>> len(cluster)
1
@@ -905,83 +908,59 @@ class Cluster(NamedTuple('Cluster',
candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude]
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
@staticmethod
def is_physical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
"""Check whether provided configuration is for permanent physical replication slot.
:param value: configuration of the permanent replication slot.
:returns: ``True`` if *value* is a physical replication slot, otherwise ``False``.
"""
return not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'
@staticmethod
def is_logical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
"""Check whether provided configuration is for permanent logical replication slot.
:param value: configuration of the permanent replication slot.
:returns: ``True`` if *value* is a logical replication slot, otherwise ``False``.
"""
return isinstance(value, dict) \
and value.get('type', 'logical') == 'logical' \
and bool(value.get('database') and value.get('plugin'))
@property
def __permanent_slots(self) -> Dict[str, Union[Dict[str, Any], Any]]:
"""Dictionary of permanent replication slots with their known LSN."""
ret: Dict[str, Union[Dict[str, Any], Any]] = global_config.permanent_slots
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()}
for name, value in list(ret.items()):
if not value:
value = ret[name] = {}
if isinstance(value, dict):
# for permanent physical slots we want to get MAX LSN from the `Cluster.slots` and from the
# member with the matching name. It is necessary because we may have the replication slot on
# the primary that is streaming from the other standby node using the `replicatefrom` tag.
lsn = max(members.get(name, 0) if self.is_physical_slot(value) else 0, slots.get(name, 0))
if lsn:
value['lsn'] = lsn
else:
# Don't let anyone set 'lsn' in the global configuration :)
value.pop('lsn', None)
ret = deepcopy(self.config.permanent_slots if self.config else {})
# If primary reported flush LSN for permanent slots we want to enrich our structure with it
for name, lsn in (self.slots or {}).items():
if name in ret:
if not ret[name]:
ret[name] = {}
if isinstance(ret[name], dict):
ret[name]['lsn'] = lsn
return ret
@property
def __permanent_physical_slots(self) -> Dict[str, Any]:
"""Dictionary of permanent ``physical`` replication slots."""
return {name: value for name, value in self.__permanent_slots.items() if self.is_physical_slot(value)}
return {name: value for name, value in self.__permanent_slots.items()
if not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'}
@property
def __permanent_logical_slots(self) -> Dict[str, Any]:
"""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 isinstance(value, dict)
and value.get('type', 'logical') == 'logical' and value.get('database') and value.get('plugin')}
def get_replication_slots(self, postgresql: 'Postgresql', member: Tags, *,
role: Optional[str] = None, show_error: bool = False) -> Dict[str, Dict[str, Any]]:
@property
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.
Will log an error if:
* Any logical slots are disabled, due to version compatibility, and *show_error* is ``True``.
:param postgresql: reference to :class:`Postgresql` object.
:param member: reference to an object implementing :class:`Tags` interface.
:param role: role of the node, if not set will be taken from *postgresql*.
:param my_name: name of this node.
:param role: role of this node.
: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.
: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
role = role or postgresql.role
slots: Dict[str, Dict[str, str]] = self._get_members_slots(name, role)
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
slots: Dict[str, Dict[str, str]] = self._get_members_slots(my_name, role)
permanent_slots: Dict[str, Any] = self._get_permanent_slots(is_standby_cluster, role, nofailover)
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:
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
@@ -989,7 +968,8 @@ class Cluster(NamedTuple('Cluster',
return slots
def _merge_permanent_slots(self, slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], name: str,
@staticmethod
def _merge_permanent_slots(slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], my_name: str,
major_version: int) -> List[str]:
"""Merge replication *slots* for members with *permanent_slots*.
@@ -999,7 +979,7 @@ class Cluster(NamedTuple('Cluster',
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 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 major_version: postgresql major version.
@@ -1007,9 +987,9 @@ class Cluster(NamedTuple('Cluster',
"""
disabled_permanent_logical_slots: List[str] = []
for slot_name, value in permanent_slots.items():
if not slot_name_re.match(slot_name):
logger.error("Invalid permanent replication slot name '%s'", slot_name)
for name, value in permanent_slots.items():
if not slot_name_re.match(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")
continue
@@ -1020,54 +1000,51 @@ class Cluster(NamedTuple('Cluster',
if value['type'] == 'physical':
# Don't try to create permanent physical replication slot for yourself
if slot_name != slot_name_from_member_name(name):
slots[slot_name] = value
if name != slot_name_from_member_name(my_name):
slots[name] = value
continue
if self.is_logical_slot(value):
if major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
disabled_permanent_logical_slots.append(slot_name)
elif slot_name in slots:
if value['type'] == 'logical' and value.get('database') and value.get('plugin'):
if major_version < 110000:
disabled_permanent_logical_slots.append(name)
elif name in slots:
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:
slots[slot_name] = value
slots[name] = value
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
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) -> Dict[str, Any]:
"""Get configured permanent replication slots.
.. note::
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.
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.
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.
:param postgresql: reference to :class:`Postgresql` object.
:param tags: reference to an object implementing :class:`Tags` interface.
:param role: role of the node -- ``primary``, ``standby_leader`` or ``replica``.
: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 role: role of this node -- ``primary``, ``standby_leader`` or ``replica``.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
: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 {}
if global_config.is_standby_cluster or self.get_slot_name_on_primary(postgresql.name, tags) is None:
return self.__permanent_physical_slots \
if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {}
if is_standby_cluster:
return self.__permanent_physical_slots if role == 'standby_leader' else {}
return self.__permanent_slots if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION\
or role in ('master', 'primary') else self.__permanent_logical_slots
return self.__permanent_slots if 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.
If the ``replicatefrom`` tag is set on the member - we should not create the replication slot for it on
@@ -1075,34 +1052,29 @@ class Cluster(NamedTuple('Cluster',
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.
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:
* 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
replicating from this node. If not then return a list of members replicating as cascaded
replicas from this 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 {}
# 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 != name and not m.nostream, self.members)
# we always want to exclude the member with our name from the list
members = filter(lambda m: m.name != my_name, self.members)
if role in ('master', 'primary', 'standby_leader'):
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:
# 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}
if len(slots) < len(members):
@@ -1115,76 +1087,46 @@ class Cluster(NamedTuple('Cluster',
for k, v in slot_conflicts.items() if len(v) > 1))
return slots
def has_permanent_slots(self, postgresql: 'Postgresql', member: Tags) -> bool:
"""Check if our node has permanent replication slots configured.
:param postgresql: reference to :class:`Postgresql` object.
:param member: reference to an object implementing :class:`Tags` interface for
the node that we are checking permanent logical replication slots for.
:returns: ``True`` if there are permanent replication slots configured, otherwise ``False``.
"""
role = 'replica'
members_slots: Dict[str, Dict[str, str]] = self._get_members_slots(postgresql.name, role)
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, member, role)
slots = deepcopy(members_slots)
self._merge_permanent_slots(slots, permanent_slots, postgresql.name, postgresql.major_version)
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]:
"""Filter out all non-permanent slots from provided *slots* dict.
:param postgresql: reference to :class:`Postgresql` object.
:param slots: slot names with LSN values.
:returns: a :class:`dict` object that contains only slots that are known to be permanent.
"""
if postgresql.major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
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')
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
and (self.is_physical_slot(permanent_slots[name])
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, major_version: int = 110000) -> bool:
"""Check if the given member node has permanent ``logical`` replication slots configured.
:param postgresql: reference to a :class:`Postgresql` object.
:param member: reference to an object implementing :class:`Tags` interface for
the node that we are checking permanent logical replication slots for.
:param my_name: name of the member node to check.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
:param major_version: the PostgreSQL major version number.
:returns: ``True`` if any detected replications slots are ``logical``, otherwise ``False``.
:returns: ``False`` if PostgreSQL is < 11, ``True`` if any detected replications slots are ``logical``.
"""
slots = self.get_replication_slots(postgresql, member, role='replica').values()
if major_version < 110000:
return False
slots = self.get_replication_slots(my_name, 'replica', nofailover, major_version).values()
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, major_version: int) -> bool:
"""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,
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 member: reference to an object implementing :class:`Tags` interface for
the node that we are checking permanent logical replication slots for.
:param my_name: name of the member node to check.
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
:param major_version: PostgreSQL major version number.
:returns: ``True`` if this node or any member replicating from this node has
permanent logical slots, otherwise ``False``.
:returns: ``True`` if this node or any member replicating from this node has permanent logical slots.
``False`` if PostgreSQL major version is < 11.
"""
if self._has_permanent_logical_slots(postgresql, member):
if major_version < 110000:
return False
if self.has_permanent_logical_slots(my_name, nofailover, major_version):
return True
if global_config.use_slots:
name = member.name if isinstance(member, Member) else postgresql.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(postgresql, m) for m in members)
if self.use_slots:
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader_name]
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover, major_version) for m in members)
return False
def get_slot_name_on_primary(self, name: str, tags: Tags) -> Optional[str]:
"""Get the name of physical replication slot for this node on the primary.
def get_my_slot_name_on_primary(self, my_name: str, replicatefrom: Optional[str]) -> str:
"""Canonical slot name for physical replication.
.. note::
P <-- I <-- L
@@ -1192,16 +1134,14 @@ class Cluster(NamedTuple('Cluster',
In case of cascading replication we have to check not our physical slot, but slot of the replica that
connects us to the primary.
:param name: name of the member node to check.
:param tags: reference to an object implementing :class:`Tags` interface.
:param my_name: the member node name that is replicating.
: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:
return None
replicatefrom = self.get_member(tags.replicatefrom, False) if tags.replicatefrom else None
return self.get_slot_name_on_primary(replicatefrom.name, replicatefrom) \
if isinstance(replicatefrom, Member) else slot_name_from_member_name(name)
m = self.get_member(replicatefrom, False) if replicatefrom else None
return self.get_my_slot_name_on_primary(m.name, m.replicatefrom) \
if isinstance(m, Member) else slot_name_from_member_name(my_name)
@property
def timeline(self) -> int:
@@ -1214,20 +1154,19 @@ class Cluster(NamedTuple('Cluster',
:Example:
No history provided:
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, 0, None, {}).timeline
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0, 0, None, {}).timeline
0
Empty history assume timeline is ``1``:
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[]'), None, {}).timeline
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]'), 0, None, {}).timeline
1
Invalid history format, a string of ``a``, returns ``0``:
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), None, {}).timeline
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), 0, None, {}).timeline
0
History as a list of strings:
>>> history = TimelineHistory.from_node(1, '[["3", "2", "1"]]')
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, history, None, {}).timeline
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["3", "2", "1"]]'), 0, None, {}).timeline
4
"""
if self.history:
@@ -1276,11 +1215,11 @@ class AbstractDCS(abc.ABC):
Functional methods that are critical in their timing, required to complete within ``retry_timeout`` period in order
to prevent the DCS considered inaccessible, each perform construction of complex data objects:
* :meth:`~AbstractDCS._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
with all relevant associated data.
* :meth:`~AbstractDCS._mpp_cluster_loader`:
Similar to above but specifically representing MPP group and workers information.
* :meth:`~AbstractDCS._citus_cluster_loader`:
Similar to above but specifically representing Citus group and workers information.
* :meth:`~AbstractDCS._load_cluster`:
main method for calling specific ``loader`` method to build the :class:`Cluster` object representing the
state and topology of the cluster.
@@ -1349,15 +1288,15 @@ class AbstractDCS(abc.ABC):
_SYNC = 'sync'
_FAILSAFE = 'failsafe'
def __init__(self, config: Dict[str, Any], mpp: 'AbstractMPP') -> None:
"""Prepare DCS paths, MPP object, initial values for state information and processing dependencies.
def __init__(self, config: Dict[str, Any]) -> None:
"""Prepare DCS paths, Citus group ID, initial values for state information and processing dependencies.
:ivar config: :class:`dict`, reference to config section of selected DCS.
i.e.: ``zookeeper`` for zookeeper, ``etcd`` for etcd, etc...
"""
self._mpp = mpp
self._name = config['name']
self._base_path = re.sub('/+', '/', '/'.join(['', config.get('namespace', 'service'), config['scope']]))
self._citus_group = str(config['group']) if isinstance(config.get('group'), int) else None
self._set_loop_wait(config.get('loop_wait', 10))
self._ctl = bool(config.get('patronictl', False))
@@ -1370,11 +1309,6 @@ class AbstractDCS(abc.ABC):
self._last_failsafe: Optional[Dict[str, str]] = {}
self.event = Event()
@property
def mpp(self) -> 'AbstractMPP':
"""Get the effective underlying MPP, if any has been configured."""
return self._mpp
def client_path(self, path: str) -> str:
"""Construct the absolute key name from appropriate parts for the DCS type.
@@ -1383,8 +1317,8 @@ class AbstractDCS(abc.ABC):
:returns: absolute key name for the current Patroni cluster.
"""
components = [self._base_path]
if self._mpp.is_enabled():
components.append(str(self._mpp.group))
if self._citus_group:
components.append(self._citus_group)
components.append(path.lstrip('/'))
return '/'.join(components)
@@ -1485,21 +1419,22 @@ class AbstractDCS(abc.ABC):
return self._last_seen
@abc.abstractmethod
def _postgresql_cluster_loader(self, path: Any) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
def _cluster_loader(self, path: Any) -> 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.
"""
@abc.abstractmethod
def _mpp_cluster_loader(self, path: Any) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
def _citus_cluster_loader(self, path: Any) -> Union[Cluster, Dict[int, Cluster]]:
"""Load and build all Patroni clusters from a single Citus 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.
: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
@@ -1514,14 +1449,16 @@ class AbstractDCS(abc.ABC):
the :meth:`~AbstractDCS.get_cluster` method.
:param path: the path in DCS where to load Cluster(s) from.
:param loader: one of :meth:`~AbstractDCS._postgresql_cluster_loader` or
:meth:`~AbstractDCS._mpp_cluster_loader`.
:param loader: one of :meth:`~AbstractDCS._cluster_loader` or :meth:`~AbstractDCS._citus_cluster_loader`.
:raise: :exc:`~DCSError` in case of communication problems with DCS. If the current node was running as a
primary and exception raised, instance would be demoted.
"""
def __get_postgresql_cluster(self, path: Optional[str] = None) -> Cluster:
def _bypass_caches(self) -> None:
"""Used only in Zookeeper."""
def __get_patroni_cluster(self, path: Optional[str] = None) -> Cluster:
"""Low level method to load a :class:`Cluster` object from DCS.
:param path: optional client path in DCS backend to load from.
@@ -1530,59 +1467,63 @@ class AbstractDCS(abc.ABC):
"""
if path is None:
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
assert isinstance(cluster, Cluster)
return cluster
def is_mpp_coordinator(self) -> bool:
""":class:`Cluster` instance has a Coordinator group ID.
def is_citus_coordinator(self) -> bool:
""":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]:
"""Load the PostgreSQL cluster for the MPP Coordinator.
def get_citus_coordinator(self) -> Optional[Cluster]:
"""Load the Patroni cluster for the Citus Coordinator.
.. note::
This method is only executed on the worker nodes to find the coordinator.
.. note::
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:
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:
logger.error('Failed to load %s coordinator cluster from %s: %r',
self._mpp.type, self.__class__.__name__, e)
logger.error('Failed to load Citus coordinator cluster from %s: %r', self.__class__.__name__, e)
return None
def _get_mpp_cluster(self) -> Cluster:
"""Load MPP cluster from DCS.
def _get_citus_cluster(self) -> Cluster:
"""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.
"""
groups = self._load_cluster(self._base_path + '/', self._mpp_cluster_loader)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(groups, dict)
cluster = groups.pop(self._mpp.coordinator_group_id, Cluster.empty())
cluster.workers.update(groups)
groups = self._load_cluster(self._base_path + '/', self._citus_cluster_loader)
if isinstance(groups, Cluster): # Zookeeper could return a cached version
cluster = groups
else:
cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
cluster.workers.update(groups)
return cluster
def get_cluster(self) -> Cluster:
"""Retrieve a fresh view of DCS.
def get_cluster(self, force: bool = False) -> Cluster:
"""Retrieve an appropriate cached or fresh view of DCS.
.. note::
Stores copy of time, status and failsafe values for comparison in DCS update decisions.
Caching is required to avoid overhead placed upon the REST API.
Returns either a PostgreSQL or MPP implementation of :class:`Cluster` depending on availability.
Returns either a Citus or Patroni implementation of :class:`Cluster` depending on availability.
:param force: a value of ``True`` will override Zookeeper caching features.
:returns:
"""
if force:
self._bypass_caches()
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:
self.reset_cluster()
raise
+34 -34
View File
@@ -15,10 +15,9 @@ from urllib3.exceptions import HTTPError
from urllib.parse import urlencode, urlparse, quote
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -233,8 +232,8 @@ def service_name_from_scope_name(scope_name: str) -> str:
class Consul(AbstractDCS):
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Consul, self).__init__(config, mpp)
def __init__(self, config: Dict[str, Any]) -> None:
super(Consul, self).__init__(config)
self._base_path = self._base_path[1:]
self._scope = config['scope']
self._session = None
@@ -384,8 +383,23 @@ class Consul(AbstractDCS):
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value'])
# get last known leader lsn and slots
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
status = Status.from_node(status and status['Value'])
status = nodes.get(self._STATUS)
if status:
try:
status = json.loads(status['Value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['Value']
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -414,42 +428,29 @@ class Consul(AbstractDCS):
except Exception:
failsafe = None
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
@property
def _consistency(self) -> str:
return 'consistent' if self._ctl else self._client.consistency
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
def _cluster_loader(self, path: str) -> Cluster:
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
if results is None:
return Cluster.empty()
nodes: Dict[str, Dict[str, Any]] = {}
raise NotFound
nodes = {}
for node in results:
node['Value'] = (node['Value'] or b'').decode('utf-8')
nodes[node['Key'][len(path):]] = node
return self._cluster_from_nodes(nodes)
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
results: Optional[List[Dict[str, Any]]]
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
_, results = self.retry(self._client.kv.get, path, recurse=True, consistency=self._consistency)
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
clusters: Dict[int, Dict[str, Cluster]] = defaultdict(dict)
for node in results or []:
key = node['Key'][len(path):].split('/', 1)
if len(key) == 2 and 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')
clusters[int(key[0])][key[1]] = node
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
@@ -459,6 +460,8 @@ class Consul(AbstractDCS):
) -> Union[Cluster, Dict[int, Cluster]]:
try:
return loader(path)
except NotFound:
return Cluster.empty()
except Exception:
logger.exception('get_cluster')
raise ConsulError('Consul is not responding properly')
@@ -578,17 +581,14 @@ class Consul(AbstractDCS):
try:
return retry(self._client.kv.put, self.leader_path, self._name, acquire=self._session)
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')
self._session = None
retry.ensure_deadline(0)
retry(self._do_refresh_session)
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)
@catch_return_false_exception
@@ -683,7 +683,7 @@ class Consul(AbstractDCS):
if ret: # We have no other choise, only read after write :(
if not retry.ensure_deadline(0.5):
return False
_, ret = self.retry(self._client.kv.get, self.sync_path, consistency='consistent')
_, ret = self.retry(self._client.kv.get, self.sync_path)
if ret and (ret.get('Value') or b'').decode('utf-8') == value:
return ret['ModifyIndex']
return False
+31 -34
View File
@@ -21,10 +21,9 @@ from urllib.parse import urlparse
from urllib3 import Timeout
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..request import get as requests_get
from ..utils import Retry, RetryFailedError, split_host_port, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
@@ -471,9 +470,9 @@ class EtcdClient(AbstractEtcdClientWithFailover):
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:
super(AbstractEtcd, self).__init__(config, mpp)
super(AbstractEtcd, self).__init__(config)
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=retry_errors_cls)
self._ttl = int(config.get('ttl') or 30)
@@ -646,8 +645,8 @@ def catch_etcd_errors(func: Callable[..., Any]) -> Any:
class Etcd(AbstractEtcd):
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Etcd, self).__init__(config, mpp, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
def __init__(self, config: Dict[str, Any]) -> None:
super(Etcd, self).__init__(config, EtcdClient, (etcd.EtcdLeaderElectionInProgress, EtcdRaftInternal))
self.__do_not_watch = False
@property
@@ -678,8 +677,23 @@ class Etcd(AbstractEtcd):
history = history and TimelineHistory.from_node(history.modifiedIndex, history.value)
# get last know leader lsn and slots
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
status = Status.from_node(status and status.value)
status = nodes.get(self._STATUS)
if status:
try:
status = json.loads(status.value)
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn.value
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -708,38 +722,19 @@ class Etcd(AbstractEtcd):
except Exception:
failsafe = None
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
try:
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
except etcd.EtcdKeyNotFound:
return Cluster.empty()
def _cluster_loader(self, path: str) -> Cluster:
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
nodes = {node.key[len(result.key):].lstrip('/'): node for node in result.leaves}
return self._cluster_from_nodes(result.etcd_index, nodes)
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
try:
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
except etcd.EtcdKeyNotFound:
return {}
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
clusters: Dict[int, Dict[str, etcd.EtcdResult]] = defaultdict(dict)
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
for node in result.leaves:
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
return {group: self._cluster_from_nodes(result.etcd_index, nodes) for group, nodes in clusters.items()}
@@ -749,6 +744,8 @@ class Etcd(AbstractEtcd):
cluster = None
try:
cluster = loader(path)
except etcd.EtcdKeyNotFound:
cluster = Cluster.empty()
except Exception as e:
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
self._has_failed = False
+72 -92
View File
@@ -15,11 +15,10 @@ from urllib3.exceptions import ReadTimeoutError, ProtocolError
from threading import Condition, Lock, Thread
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
from . import ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
TimelineHistory, catch_return_false_exception
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, \
TimelineHistory, catch_return_false_exception, citus_group_re
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
from ..exceptions import DCSError, PatroniException
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, enable_keepalive, iter_response_objects, RetryFailedError, USER_AGENT
logger = logging.getLogger(__name__)
@@ -125,10 +124,6 @@ class AuthFailed(InvalidArgument):
error = "etcdserver: authentication failed, invalid user ID or password"
class AuthOldRevision(InvalidArgument):
error = "etcdserver: revision of auth store is old"
class PermissionDenied(Etcd3ClientError):
code = GRPCCode.PermissionDenied
error = "etcdserver: permission denied"
@@ -209,9 +204,8 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
ERROR_CLS = Etcd3Error
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
self._reauthenticate = False
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)
try:
@@ -288,7 +282,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
fields['retry'] = retry
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
def authenticate(self, *, retry: Optional[Retry] = None) -> bool:
def authenticate(self) -> bool:
if self._use_proxies and not self._cluster_version:
kwargs = self._prepare_common_parameters(1)
self._ensure_version_prefix(self._base_uri, **kwargs)
@@ -297,7 +291,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
logger.info('Trying to authenticate on Etcd...')
old_token, self._token = self._token, None
try:
response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password}, retry)
response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password})
except AuthNotEnabled:
logger.info('Etcd authentication is not enabled')
self._token = None
@@ -308,64 +302,48 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
self._token = response.get('token')
return old_token != self._token
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any,
retry: Optional[Retry] = None, **kwargs: Any) -> Any:
reauthenticated = False
exc = None
while True:
if self._reauthenticate:
if self.username and self.password:
self.authenticate(retry=retry)
self._reauthenticate = False
else:
msg = 'Username or password not set, authentication is not possible'
logger.fatal(msg)
raise exc or Etcd3Exception(msg)
reauthenticated = True
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
def retry(ex: Exception) -> Any:
if self.username and self.password:
self.authenticate()
return func(self, *args, **kwargs)
else:
logger.fatal('Username or password not set, authentication is not possible')
raise ex
try:
return func(self, *args, retry=retry, **kwargs)
except (UserEmpty, PermissionDenied) as e: # no token provided
# PermissionDenied is raised on 3.0 and 3.1
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied)
or self._cluster_version < (3, 2)):
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
'supported on version lower than 3.3.0. Cluster version: '
'{0}'.format('.'.join(map(str, self._cluster_version))))
exc = e
except InvalidAuthToken as e:
logger.error('Invalid auth token: %s', self._token)
exc = e
except AuthOldRevision as e:
logger.error('Auth token is for old revision of auth store')
exc = e
self._reauthenticate = True
if retry:
logger.error('retry = %s', retry)
retry.ensure_deadline(0.5, exc)
elif reauthenticated:
raise exc
try:
return func(self, *args, **kwargs)
except (UserEmpty, PermissionDenied) as e: # no token provided
# PermissionDenied is raised on 3.0 and 3.1
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied)
or self._cluster_version < (3, 2)):
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
'supported on version lower than 3.3.0. Cluster version: '
'{0}'.format('.'.join(map(str, self._cluster_version))))
return retry(e)
except InvalidAuthToken as e:
logger.error('Invalid auth token: %s', self._token)
return retry(e)
@_handle_auth_errors
def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
*, retry: Optional[Retry] = None) -> Dict[str, Any]:
retry: Optional[Retry] = None) -> Dict[str, Any]:
params = build_range_request(key, range_end)
params['serializable'] = serializable # For better performance. We can tolerate stale reads
return self.call_rpc('/kv/range', params, retry)
def prefix(self, key: str, serializable: bool = True, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
return self.range(key, prefix_range_end(key), serializable, retry=retry)
def prefix(self, key: str, serializable: bool = True, retry: Optional[Retry] = None) -> Dict[str, Any]:
return self.range(key, prefix_range_end(key), serializable, retry)
@_handle_auth_errors
def lease_grant(self, ttl: int, *, retry: Optional[Retry] = None) -> str:
def lease_grant(self, ttl: int, retry: Optional[Retry] = None) -> str:
return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID']
def lease_keepalive(self, ID: str, *, retry: Optional[Retry] = None) -> Optional[str]:
def lease_keepalive(self, ID: str, retry: Optional[Retry] = None) -> Optional[str]:
return self.call_rpc('/lease/keepalive', {'ID': ID}, retry).get('result', {}).get('TTL')
@_handle_auth_errors
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
failure: Optional[Dict[str, Any]] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = {'compare': [compare], 'success': [success]}
if failure:
fields['failure'] = [failure]
@@ -374,7 +352,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
@_handle_auth_errors
def put(self, key: str, value: str, lease: Optional[str] = None, create_revision: Optional[str] = None,
mod_revision: Optional[str] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = {'key': base64_encode(key), 'value': base64_encode(value)}
if lease:
fields['lease'] = lease
@@ -389,14 +367,14 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
@_handle_auth_errors
def deleterange(self, key: str, range_end: Union[bytes, str, None] = None,
mod_revision: Optional[str] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
fields = build_range_request(key, range_end)
if mod_revision is None:
return self.call_rpc('/kv/deleterange', fields, retry)
compare = {'target': 'MOD', 'mod_revision': mod_revision, 'key': fields['key']}
return self.txn(compare, {'request_delete_range': fields}, retry=retry)
def deleteprefix(self, key: str, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
def deleteprefix(self, key: str, retry: Optional[Retry] = None) -> Dict[str, Any]:
return self.deleterange(key, prefix_range_end(key), retry=retry)
def watchrange(self, key: str, range_end: Union[bytes, str, None] = None,
@@ -596,6 +574,12 @@ class PatroniEtcd3Client(Etcd3Client):
super(PatroniEtcd3Client, self).set_base_uri(value)
self._restart_watcher()
def authenticate(self) -> bool:
ret = super(PatroniEtcd3Client, self).authenticate()
if ret:
self._restart_watcher()
return ret
def _wait_cache(self, timeout: float) -> None:
stop_time = time.time() + timeout
while self._kv_cache and not self._kv_cache.is_ready():
@@ -647,8 +631,8 @@ class PatroniEtcd3Client(Etcd3Client):
return ret
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
failure: Optional[Dict[str, Any]] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry=retry)
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry)
# Here we abuse the fact that the `failure` is only set in the call from update_leader().
# In all other cases the txn() call failure may be an indicator of a stale cache,
# and therefore we want to restart watcher.
@@ -659,9 +643,8 @@ class PatroniEtcd3Client(Etcd3Client):
class Etcd3(AbstractEtcd):
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Etcd3, self).__init__(config, mpp, PatroniEtcd3Client,
(DeadlineExceeded, Unavailable, FailedPrecondition))
def __init__(self, config: Dict[str, Any]) -> None:
super(Etcd3, self).__init__(config, PatroniEtcd3Client, (DeadlineExceeded, Unavailable, FailedPrecondition))
self.__do_not_watch = False
self._lease = None
self._last_lease_refresh = 0
@@ -693,12 +676,12 @@ class Etcd3(AbstractEtcd):
if not force and self._lease and self._last_lease_refresh + self._loop_wait > time.time():
return False
if self._lease and not self._client.lease_keepalive(self._lease, retry=retry):
if self._lease and not self._client.lease_keepalive(self._lease, retry):
self._lease = None
ret = not self._lease
if ret:
self._lease = self._client.lease_grant(self._ttl, retry=retry)
self._lease = self._client.lease_grant(self._ttl, retry)
self._last_lease_refresh = time.time()
return ret
@@ -720,11 +703,7 @@ class Etcd3(AbstractEtcd):
@property
def cluster_prefix(self) -> str:
"""Construct the cluster prefix for the cluster.
:returns: path in the DCS under which we store information about this Patroni cluster.
"""
return self._base_path + '/' if self.is_mpp_coordinator() else self.client_path('')
return self._base_path + '/' if self.is_citus_coordinator() else self.client_path('')
@staticmethod
def member(node: Dict[str, str]) -> Member:
@@ -744,8 +723,23 @@ class Etcd3(AbstractEtcd):
history = history and TimelineHistory.from_node(history['mod_revision'], history['value'])
# get last know leader lsn and slots
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
status = Status.from_node(status and status['value'])
status = nodes.get(self._STATUS)
if status:
try:
status = json.loads(status['value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['value']
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
# get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -776,32 +770,20 @@ class Etcd3(AbstractEtcd):
except Exception:
failsafe = None
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
def _cluster_loader(self, path: str) -> Cluster:
nodes = {node['key'][len(path):]: node
for node in self._client.get_cluster(path)
if node['key'].startswith(path)}
return self._cluster_from_nodes(nodes)
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
clusters: Dict[int, Dict[str, Dict[str, Any]]] = defaultdict(dict)
path = self._base_path + '/'
for node in self._client.get_cluster(path):
key = node['key'][len(path):].split('/', 1)
if len(key) == 2 and 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
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
@@ -853,16 +835,14 @@ class Etcd3(AbstractEtcd):
try:
return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')
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')
self._lease = None
retry.ensure_deadline(0)
_retry(self._do_refresh_lease)
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')
@catch_return_false_exception
+2 -3
View File
@@ -7,7 +7,6 @@ from typing import Any, Callable, Dict, List, Union
from . import Cluster
from .zookeeper import ZooKeeper
from ..postgresql.mpp import AbstractMPP
from ..request import get as requests_get
from ..utils import uri
@@ -67,10 +66,10 @@ class ExhibitorEnsembleProvider(object):
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)
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(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
+51 -81
View File
@@ -19,10 +19,9 @@ from urllib3.exceptions import HTTPError
from threading import Condition, Lock, Thread
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, TimelineHistory
from ..collections import EMPTY_DICT
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
Retry, RetryFailedError, tzutc, uri, USER_AGENT
if TYPE_CHECKING: # pragma: no cover
@@ -471,7 +470,7 @@ class K8sClient(object):
if len(args) == 3: # name, namespace, body
body = args[2]
elif action == 'create': # namespace, body
body = args[1] # pyright: ignore [reportGeneralTypeIssues]
body = args[1]
elif action == 'delete': # name, namespace
body = kwargs.pop('body', None)
else:
@@ -510,7 +509,7 @@ class KubernetesRetriableException(k8s_client.rest.ApiException):
@property
def sleeptime(self) -> Optional[int]:
try:
return int((self.headers or EMPTY_DICT).get('retry-after', ''))
return int((self.headers or {}).get('retry-after', ''))
except Exception:
return None
@@ -655,7 +654,7 @@ class ObjectCache(Thread):
obj = K8sObject(obj)
success, old_value = self.set(name, obj)
if success:
new_value = (obj.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
new_value = (obj.metadata.annotations or {}).get(self._annotations_map.get(name))
elif ev_type == 'DELETED':
success, old_value = self.delete(name, obj['metadata']['resourceVersion'])
else:
@@ -663,7 +662,7 @@ class ObjectCache(Thread):
if success and obj.get('kind') != 'Pod':
if old_value:
old_value = (old_value.metadata.annotations or EMPTY_DICT).get(self._annotations_map.get(name, ''))
old_value = (old_value.metadata.annotations or {}).get(self._annotations_map.get(name))
value_changed = old_value != new_value and \
(name != self._dcs.config_path or old_value is not None and new_value is not None)
@@ -747,7 +746,9 @@ class ObjectCache(Thread):
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[config.get('scope_label', 'cluster-name')] = config['scope']
self._label_selector = ','.join('{0}={1}'.format(k, v) for k, v in self._labels.items())
@@ -758,9 +759,9 @@ class Kubernetes(AbstractDCS):
self._standby_leader_label_value = config.get('standby_leader_label_value', 'master')
self._tmp_role_label = config.get('tmp_role_label')
self._ca_certs = os.environ.get('PATRONI_KUBERNETES_CACERT', config.get('cacert')) or SERVICE_CERT_FILENAME
super(Kubernetes, self).__init__({**config, 'namespace': ''}, mpp)
if self._mpp.is_enabled():
self._labels[self._mpp.k8s_group_label] = str(self._mpp.group)
super(Kubernetes, self).__init__({**config, 'namespace': ''})
if self._citus_group:
self._labels[self._CITUS_LABEL] = self._citus_group
self._retry = Retry(deadline=config['retry_timeout'], max_delay=1, max_tries=-1,
retry_exceptions=KubernetesRetriableException)
@@ -770,7 +771,8 @@ class Kubernetes(AbstractDCS):
except k8s_config.ConfigException:
k8s_config.load_kube_config(context=config.get('context', 'kind-kind'))
self.__ips: List[str] = [] if self._ctl else [config.get('pod_ip', '')]
pod_ip = config.get('pod_ip')
self.__ips: List[str] = [] if self._ctl or not isinstance(pod_ip, str) else [pod_ip]
self.__ports: List[K8sObject] = []
ports: List[Dict[str, Any]] = config.get('ports', [{}])
for p in ports:
@@ -834,7 +836,7 @@ class Kubernetes(AbstractDCS):
self._api.configure_timeouts(self.loop_wait, self._retry.deadline, self.ttl)
# retriable_http_codes supposed to be either int, list of integers or comma-separated string with integers.
retriable_http_codes: Union[str, List[Union[str, int]]] = config.get('retriable_http_codes', [])
retriable_http_codes = config.get('retriable_http_codes', [])
if not isinstance(retriable_http_codes, list):
retriable_http_codes = [c.strip() for c in str(retriable_http_codes).split(',')]
@@ -845,7 +847,7 @@ class Kubernetes(AbstractDCS):
@staticmethod
def member(pod: K8sObject) -> Member:
annotations = pod.metadata.annotations or EMPTY_DICT
annotations = pod.metadata.annotations or {}
member = Member.from_node(pod.metadata.resource_version, pod.metadata.name, None, annotations.get('status', ''))
member.data['pod_labels'] = pod.metadata.labels
return member
@@ -886,8 +888,18 @@ class Kubernetes(AbstractDCS):
self._leader_resource_version = metadata.resource_version if metadata else None
annotations: Dict[str, str] = metadata and metadata.annotations or {}
# get last known leader lsn and slots
status = Status.from_node(annotations)
# get last known leader lsn
try:
last_lsn = int(annotations.get(self._OPTIME, ''))
except Exception:
last_lsn = 0
# get permanent slots state (confirmed_flush_lsn)
slots = annotations.get('slots')
try:
slots = json.loads(annotations.get('slots', ''))
except Exception:
slots = None
# get failsafe topology
try:
@@ -926,41 +938,29 @@ class Kubernetes(AbstractDCS):
failover = nodes.get(path + self._FAILOVER)
metadata = failover and failover.metadata
failover = metadata and Failover.from_node(metadata.resource_version,
(metadata.annotations or EMPTY_DICT).copy())
(metadata.annotations or {}).copy())
# get synchronization state
sync = nodes.get(path + self._SYNC)
metadata = sync and sync.metadata
sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations)
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _postgresql_cluster_loader(self, path: Dict[str, Any]) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
def _cluster_loader(self, path: Dict[str, Any]) -> Cluster:
return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values())
def _mpp_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
def _citus_cluster_loader(self, path: Dict[str, Any]) -> Dict[int, Cluster]:
clusters: Dict[str, Dict[str, Dict[str, K8sObject]]] = defaultdict(lambda: defaultdict(dict))
for name, pod in path['pods'].items():
group = pod.metadata.labels.get(self._mpp.k8s_group_label)
if group and self._mpp.group_re.match(group):
group = pod.metadata.labels.get(self._CITUS_LABEL)
if group and citus_group_re.match(group):
clusters[group]['pods'][name] = pod
for name, kind in path['nodes'].items():
group = kind.metadata.labels.get(self._mpp.k8s_group_label)
if group and self._mpp.group_re.match(group):
group = kind.metadata.labels.get(self._CITUS_LABEL)
if group and citus_group_re.match(group):
clusters[group]['nodes'][name] = kind
return {int(group): self._cluster_from_nodes(group, value['nodes'], value['pods'].values())
for group, value in clusters.items()}
@@ -976,9 +976,9 @@ class Kubernetes(AbstractDCS):
with self._condition:
self._wait_caches(stop_time)
pods = {name: pod for name, pod in self._pods.copy().items()
if not group or pod.metadata.labels.get(self._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()
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})
except Exception:
logger.exception('get_cluster')
@@ -987,24 +987,17 @@ class Kubernetes(AbstractDCS):
def _load_cluster(
self, path: str, loader: Callable[[Any], 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)
def get_mpp_coordinator(self) -> Optional[Cluster]:
"""Load the PostgreSQL cluster for the MPP Coordinator.
.. note::
This method is only executed on the worker nodes to find the coordinator.
:returns: Select :class:`Cluster` instance associated with the MPP Coordinator group ID.
"""
def get_citus_coordinator(self) -> Optional[Cluster]:
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
assert isinstance(ret, Cluster)
return ret
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
def compare_ports(p1: K8sObject, p2: K8sObject) -> bool:
@@ -1048,9 +1041,8 @@ class Kubernetes(AbstractDCS):
def __target_ref(self, leader_ip: str, latest_subsets: List[K8sObject], pod: K8sObject) -> K8sObject:
# we want to re-use existing target_ref if possible
empty_addresses: List[K8sObject] = []
for subset in latest_subsets:
for address in subset.addresses or empty_addresses:
for address in subset.addresses or []:
if address.ip == leader_ip and address.target_ref and address.target_ref.name == self._name:
return address.target_ref
return k8s_client.V1ObjectReference(kind='Pod', uid=pod.metadata.uid, namespace=self._namespace,
@@ -1058,8 +1050,7 @@ class Kubernetes(AbstractDCS):
def _map_subsets(self, endpoints: Dict[str, Any], ips: List[str]) -> None:
leader = self._kinds.get(self.leader_path)
empty_addresses: List[K8sObject] = []
latest_subsets = leader and leader.subsets or empty_addresses
latest_subsets = leader and leader.subsets or []
if not ips:
# We want to have subsets empty
if latest_subsets:
@@ -1078,27 +1069,6 @@ class Kubernetes(AbstractDCS):
def _patch_or_create(self, name: str, annotations: Dict[str, Any],
resource_version: Optional[str] = None, patch: bool = False,
retry: Optional[Callable[..., Any]] = None, ips: Optional[List[str]] = None) -> K8sObject:
"""Patch or create K8s object, Endpoint or ConfigMap.
:param name: the name of the object.
:param annotations: mapping of annotations that we want to create/update.
:param resource_version: object should be updated only if the ``resource_version`` matches provided value.
:param patch: ``True`` if we know in advance that the object already exists and we should patch it.
:param retry: a callable that will take care of retries
:param ips: IP address that we want to put to the subsets of the endpoint. Could have following values:
* ``None`` - when we don't need to touch subset;
* ``[]`` - to set subsets to the empty list, when :meth:`delete_leader` method is called;
* ``['ip.add.re.ss']`` - when we want to make sure that the subsets of the leader endpoint
contains the IP address of the leader, that we get from the ``kubernetes.pod_ip``;
* ``['']`` - when we want to make sure that the subsets of the leader endpoint contains the IP
address of the leader, but ``kubernetes.pod_ip`` configuration is missing. In this case we will
try to take the IP address of the Pod which name matches ``name`` from the config file.
:returns: the new :class:`V1Endpoints` or :class:`V1ConfigMap` object, that was created or updated.
"""
metadata = {'namespace': self._namespace, 'name': name, 'labels': self._labels, 'annotations': annotations}
if patch or resource_version:
if resource_version is not None:
@@ -1111,10 +1081,9 @@ class Kubernetes(AbstractDCS):
metadata['annotations'] = {k: v for k, v in annotations.items() if v is not None}
metadata = k8s_client.V1ObjectMeta(**metadata)
if self._api.use_endpoints:
if ips is not None and self._api.use_endpoints:
endpoints = {'metadata': metadata}
if ips is not None:
self._map_subsets(endpoints, ips)
self._map_subsets(endpoints, ips)
body = k8s_client.V1Endpoints(**endpoints)
else:
body = k8s_client.V1ConfigMap(metadata=metadata)
@@ -1215,7 +1184,7 @@ class Kubernetes(AbstractDCS):
if not retry.ensure_deadline(0.5):
return False
kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
kind_annotations = kind and kind.metadata.annotations or {}
kind_resource_version = kind and kind.metadata.resource_version
# There is different leader or resource_version in cache didn't change
@@ -1228,7 +1197,7 @@ class Kubernetes(AbstractDCS):
def update_leader(self, leader: Leader, last_lsn: Optional[int],
slots: Optional[Dict[str, int]] = None, failsafe: Optional[Dict[str, str]] = None) -> bool:
kind = self._kinds.get(self.leader_path)
kind_annotations = kind and kind.metadata.annotations or EMPTY_DICT
kind_annotations = kind and kind.metadata.annotations or {}
if kind and kind_annotations.get(self._LEADER) != self._name:
return False
@@ -1263,10 +1232,11 @@ class Kubernetes(AbstractDCS):
else:
annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now
annotations['transitions'] = str(transitions)
ips: Optional[List[str]] = [] if self._api.use_endpoints else None
try:
ret = bool(self._patch_or_create(self.leader_path, annotations,
self._leader_resource_version, retry=self.retry, ips=self.__ips))
self._leader_resource_version, retry=self.retry, ips=ips))
except k8s_client.rest.ApiException as e:
if e.status == 409 and self._leader_resource_version: # Conflict in resource_version
# Terminate watchers, it could be a sign that K8s API is in a failed state
@@ -1349,7 +1319,7 @@ class Kubernetes(AbstractDCS):
def delete_leader(self, leader: Optional[Leader], last_lsn: Optional[int] = None) -> bool:
ret = False
kind = self._kinds.get(self.leader_path)
if kind and (kind.metadata.annotations or EMPTY_DICT).get(self._LEADER) == self._name:
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name:
annotations: Dict[str, Optional[str]] = {self._LEADER: None}
if last_lsn:
annotations[self._OPTIME] = str(last_lsn)
+24 -22
View File
@@ -12,9 +12,8 @@ from pysyncobj.transport import TCPTransport, CONNECTION_STATE
from pysyncobj.utility import TcpUtility
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import validate_directory
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -285,8 +284,8 @@ class KVStoreTTL(DynMemberSyncObj):
class Raft(AbstractDCS):
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(Raft, self).__init__(config, mpp)
def __init__(self, config: Dict[str, Any]) -> None:
super(Raft, self).__init__(config)
self._ttl = int(config.get('ttl') or 30)
ready_event = threading.Event()
@@ -344,8 +343,23 @@ class Raft(AbstractDCS):
history = history and TimelineHistory.from_node(history['index'], history['value'])
# get last know leader lsn and slots
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
status = Status.from_node(status and status['value'])
status = nodes.get(self._STATUS)
if status:
try:
status = json.loads(status['value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['value']
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
# get list of members
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -373,33 +387,21 @@ class Raft(AbstractDCS):
except Exception:
failsafe = None
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
def _cluster_loader(self, path: str) -> Cluster:
response = self._sync_obj.get(path, recursive=True)
if not response:
return Cluster.empty()
nodes = {key[len(path):]: value for key, value in response.items()}
return self._cluster_from_nodes(nodes)
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
clusters: Dict[int, Dict[str, Any]] = defaultdict(dict)
response = self._sync_obj.get(path, recursive=True)
for key, value in (response or {}).items():
key = key[len(path):].split('/', 1)
if len(key) == 2 and 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
return {group: self._cluster_from_nodes(nodes) for group, nodes in clusters.items()}
+115 -67
View File
@@ -12,9 +12,8 @@ from kazoo.retry import RetryFailedError
from kazoo.security import ACL, make_acl
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, TimelineHistory
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
from ..exceptions import DCSError
from ..postgresql.mpp import AbstractMPP
from ..utils import deep_compare
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
@@ -87,10 +86,10 @@ class PatroniKazooClient(KazooClient):
class ZooKeeper(AbstractDCS):
def __init__(self, config: Dict[str, Any], mpp: AbstractMPP) -> None:
super(ZooKeeper, self).__init__(config, mpp)
def __init__(self, config: Dict[str, Any]) -> None:
super(ZooKeeper, self).__init__(config)
hosts: Union[str, List[str]] = config.get('hosts', [])
hosts = config.get('hosts', [])
if isinstance(hosts, list):
hosts = ','.join(hosts)
@@ -115,9 +114,11 @@ class ZooKeeper(AbstractDCS):
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
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,
deadline=config['retry_timeout'], sleep_func=time.sleep),
auth_data=list(config.get('auth_data', {}).items()), **kwargs)
deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs)
self._client.add_listener(self.session_listener)
self._fetch_cluster: bool = True
self._fetch_status: bool = True
self.__last_member_data: Optional[Dict[str, Any]] = None
self._orig_kazoo_connect = self._client._connection._connect
@@ -140,9 +141,18 @@ class ZooKeeper(AbstractDCS):
ret = self._orig_kazoo_connect(*args)
return max(self.loop_wait - 2, 2) * 1000, ret[1]
def _watcher(self, event: WatchedEvent) -> None:
if event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
self.event.set()
def session_listener(self, state: str) -> None:
if state in [KazooState.SUSPENDED, KazooState.LOST]:
self.cluster_watcher(None)
def status_watcher(self, event: Optional[WatchedEvent]) -> None:
self._fetch_status = True
self.event.set()
def cluster_watcher(self, event: Optional[WatchedEvent]) -> None:
self._fetch_cluster = True
if not event or event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
self.status_watcher(event)
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
self.set_retry_timeout(config['retry_timeout'])
@@ -190,101 +200,138 @@ class ZooKeeper(AbstractDCS):
except NoNodeError:
return None
def get_status(self, path: str, leader: Optional[Leader]) -> Status:
status = self.get_node(path + self._STATUS)
if not status:
status = self.get_node(path + self._LEADER_OPTIME)
return Status.from_node(status and status[0])
def get_status(self, path: str, leader: Optional[Leader]) -> Tuple[int, Optional[Dict[str, int]]]:
watch = self.status_watcher if not leader or leader.name != self._name else None
status = self.get_node(path + self._STATUS, watch)
if status:
try:
status = json.loads(status[0])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = self.get_node(path + self._LEADER_OPTIME, watch)
last_lsn = last_lsn and last_lsn[0]
slots = None
try:
last_lsn = int(last_lsn or '')
except Exception:
last_lsn = 0
self._fetch_status = False
return last_lsn, slots
@staticmethod
def member(name: str, value: str, znode: ZnodeStat) -> Member:
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
def get_children(self, key: str) -> List[str]:
def get_children(self, key: str, watch: Optional[Callable[[WatchedEvent], None]] = None) -> List[str]:
try:
return self._client.get_children(key)
return self._client.get_children(key, watch)
except NoNodeError:
return []
def load_members(self, path: str) -> List[Member]:
members: List[Member] = []
for member in self.get_children(path + self._MEMBERS):
for member in self.get_children(path + self._MEMBERS, self.cluster_watcher):
data = self.get_node(path + self._MEMBERS + member)
if data is not None:
members.append(self.member(member, *data))
return members
def _postgresql_cluster_loader(self, path: str) -> Cluster:
"""Load and build the :class:`Cluster` object from DCS, which represents a single PostgreSQL cluster.
:param path: the path in DCS where to load :class:`Cluster` from.
:returns: :class:`Cluster` instance.
"""
nodes = set(self.get_children(path))
def _cluster_loader(self, path: str) -> Cluster:
self._fetch_cluster = False
self.event.clear()
nodes = set(self.get_children(path, self.cluster_watcher))
if not nodes:
self._fetch_cluster = True
# get initialize flag
initialize = (self.get_node(path + self._INITIALIZE) or [None])[0] if self._INITIALIZE in nodes else None
# get global dynamic configuration
config = self.get_node(path + self._CONFIG, watch=self._watcher) if self._CONFIG in nodes else None
config = self.get_node(path + self._CONFIG, watch=self.cluster_watcher) if self._CONFIG in nodes else None
config = config and ClusterConfig.from_node(config[1].version, config[0], config[1].mzxid)
# get timeline history
history = self.get_node(path + self._HISTORY) if self._HISTORY in nodes else None
history = self.get_node(path + self._HISTORY, watch=self.cluster_watcher) if self._HISTORY in nodes else None
history = history and TimelineHistory.from_node(history[1].mzxid, history[0])
# get synchronization state
sync = self.get_node(path + self._SYNC) if self._SYNC in nodes else None
sync = self.get_node(path + self._SYNC, watch=self.cluster_watcher) if self._SYNC in nodes else None
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
# get list of members
members = self.load_members(path) if self._MEMBERS[:-1] in nodes else []
# get leader
leader = self.get_node(path + self._LEADER, watch=self._watcher) if self._LEADER in nodes else None
leader = self.get_node(path + self._LEADER) if self._LEADER in nodes else None
if leader:
member = Member(-1, leader[0], None, {})
member = ([m for m in members if m.name == leader[0]] or [member])[0]
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self._fetch_cluster = member.version == -1
# get last known leader lsn and slots
status = self.get_status(path, leader)
last_lsn, slots = self.get_status(path, leader)
# failover key
failover = self.get_node(path + self._FAILOVER) if self._FAILOVER in nodes else None
failover = self.get_node(path + self._FAILOVER, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
failover = failover and Failover.from_node(failover[1].version, failover[0])
# get failsafe topology
failsafe = self.get_node(path + self._FAILSAFE) if self._FAILSAFE in nodes else None
failsafe = self.get_node(path + self._FAILSAFE, watch=self.cluster_watcher) if self._FAILSAFE in nodes else None
try:
failsafe = json.loads(failsafe[0]) if failsafe else None
except Exception:
failsafe = None
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
def _mpp_cluster_loader(self, path: str) -> Dict[int, Cluster]:
"""Load and build all PostgreSQL clusters from a single MPP cluster.
:param path: the path in DCS where to load Cluster(s) from.
:returns: all MPP groups as :class:`dict`, with group IDs as keys and :class:`Cluster` objects as values.
"""
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
fetch_cluster = False
ret: Dict[int, Cluster] = {}
for node in self.get_children(path):
if self._mpp.group_re.match(node):
ret[int(node)] = self._postgresql_cluster_loader(path + node + '/')
for node in self.get_children(path, self.cluster_watcher):
if citus_group_re.match(node):
ret[int(node)] = self._cluster_loader(path + node + '/')
fetch_cluster = fetch_cluster or self._fetch_cluster
self._fetch_cluster = fetch_cluster
return ret
def _load_cluster(
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
) -> Union[Cluster, Dict[int, Cluster]]:
try:
return self._client.retry(loader, path)
except Exception:
logger.exception('get_cluster')
raise ZooKeeperError('ZooKeeper in not responding properly')
cluster = self.cluster if path == self._base_path + '/' else None
if self._fetch_cluster or cluster is None:
try:
cluster = self._client.retry(loader, path)
except Exception:
logger.exception('get_cluster')
self.cluster_watcher(None)
raise ZooKeeperError('ZooKeeper in not responding properly')
# The /status ZNode was updated or doesn't exist
elif self._fetch_status and not self._fetch_cluster or not cluster.last_lsn \
or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots:
# If current node is the leader just clear the event without fetching anything (we are updating the /status)
if cluster.leader and cluster.leader.name == self._name:
self.event.clear()
else:
try:
last_lsn, slots = self.get_status(self.client_path(''), cluster.leader)
self.event.clear()
new_cluster: List[Any] = list(cluster)
new_cluster[3] = last_lsn
new_cluster[8] = slots
cluster = Cluster(*new_cluster)
except Exception:
pass
return cluster
def _bypass_caches(self) -> None:
self._fetch_cluster = True
def _create(self, path: str, value: bytes, retry: bool = False, ephemeral: bool = False) -> bool:
try:
@@ -346,17 +393,21 @@ class ZooKeeper(AbstractDCS):
cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
member_data = self.__last_member_data or member and member.data
if member and member_data:
# We want delete the member ZNode if our session doesn't match with session id on our member key
if self._client.client_id is not None and member.session != self._client.client_id[0]:
logger.warning('Recreating the member ZNode due to ownership mismatch')
try:
self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError:
pass
except Exception:
return False
member = None
# We want to notify leader if some important fields in the member key changed by removing ZNode
if member and (self._client.client_id is not None and member.session != self._client.client_id[0]
or not (member_data and deep_compare(member_data.get('tags', {}), data.get('tags', {}))
and (member_data.get('state') == data.get('state')
or 'running' not in (member_data.get('state'), data.get('state')))
and member_data.get('version') == data.get('version')
and member_data.get('checkpoint_after_promote')
== data.get('checkpoint_after_promote'))):
try:
self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError:
pass
except Exception:
return False
member = None
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
if member and member_data:
@@ -448,10 +499,7 @@ class ZooKeeper(AbstractDCS):
return self.set_sync_state_value("{}", version) is not False
def watch(self, leader_version: Optional[int], timeout: float) -> bool:
if leader_version:
timeout += 0.5
try:
return super(ZooKeeper, self).watch(leader_version, timeout)
finally:
self.event.clear()
ret = super(ZooKeeper, self).watch(leader_version, timeout + 0.5)
if ret and not self._fetch_status:
self._fetch_cluster = True
return ret or self._fetch_cluster
-96
View File
@@ -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)
-229
View File
@@ -1,229 +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 .collections import EMPTY_DICT
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_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 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 EMPTY_DICT).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 EMPTY_DICT.copy())
sys.modules[__name__] = GlobalConfig()
+91 -155
View File
@@ -10,11 +10,11 @@ from multiprocessing.pool import ThreadPool
from threading import RLock
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 .async_executor import AsyncExecutor, CriticalTask
from .collections import CaseInsensitiveSet
from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember, Status, slot_name_from_member_name
from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember
from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException
from .postgresql.callback_executor import CallbackAction
from .postgresql.misc import postgres_version_to_int
@@ -123,8 +123,7 @@ class Failsafe(object):
leader = self.leader
if leader:
# We rely on the strict order of fields in the namedtuple
status = Status(cluster.status.last_lsn, leader.member.data['slots'])
cluster = Cluster(*cluster[0:2], leader, status, *cluster[4:])
cluster = Cluster(*cluster[0:2], leader, *cluster[3:8], leader.member.data['slots'], *cluster[9:])
return cluster
def is_active(self) -> bool:
@@ -156,6 +155,7 @@ class Ha(object):
self._rewind = Rewind(self.state_handler)
self.dcs = patroni.dcs
self.cluster = Cluster.empty()
self.global_config = self.patroni.config.get_global_config(None)
self.old_cluster = Cluster.empty()
self._leader_expiry = 0
self._leader_expiry_lock = RLock()
@@ -175,7 +175,7 @@ class Ha(object):
# Count of concurrent sync disabling requests. Value above zero means that we don't want to be synchronous
# standby. Changes protected by _member_state_lock.
self._disable_sync = 0
# Remember the last known member role and state written to the DCS in order to notify MPP coordinator
# Remember the last known member role and state written to the DCS in order to notify Citus coordinator
self._last_state = None
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres
@@ -185,25 +185,22 @@ class Ha(object):
# used only in backoff after failing a pre_promote script
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]:
""":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
def is_paused(self) -> bool:
""":returns: `True` if in maintenance mode."""
return global_config.is_paused
return self.global_config.is_paused
def check_timeline(self) -> bool:
""":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:
""":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:
""":returns: `True` if the current node is the leader, based on expiration set when it last held the key."""
@@ -237,7 +234,7 @@ class Ha(object):
"""
if not self.cluster.failover:
return 'failover'
return 'switchover' if self.cluster.failover.leader else 'manual failover'
return 'switchover' if self.cluster.failover.is_switchover else 'manual failover'
def load_cluster_from_dcs(self) -> None:
cluster = self.dcs.get_cluster()
@@ -274,31 +271,12 @@ class Ha(object):
ret[self.state_handler.name] = self.patroni.api.connection_string
return ret
def update_lock(self, update_status: bool = False) -> bool:
"""Update the leader lock in DCS.
.. note::
After successful update of the leader key the :meth:`AbstractDCS.update_leader` method could also
optionally update the ``/status`` and ``/failsafe`` keys.
The ``/status`` key contains the last known LSN on the leader node and the last known state
of permanent replication slots including permanent physical replication slot for the leader.
Last, but not least, this method calls a :meth:`Watchdog.keepalive` method after the leader key
was successfully updated.
:param update_status: ``True`` if we also need to update the ``/status`` key in DCS, otherwise ``False``.
:returns: ``True`` if the leader key was successfully updated and we can continue to run postgres
as a ``primary`` or as a ``standby_leader``, otherwise ``False``.
"""
def update_lock(self, write_leader_optime: bool = False) -> bool:
last_lsn = slots = None
if update_status:
if write_leader_optime:
try:
last_lsn = self.state_handler.last_operation()
slots = self.cluster.filter_permanent_slots(
self.state_handler,
{**self.state_handler.slots(), slot_name_from_member_name(self.state_handler.name): last_lsn})
slots = self.state_handler.slots()
except Exception:
logger.exception('Exception when called state_handler.last_operation()')
if TYPE_CHECKING: # pragma: no cover
@@ -329,26 +307,20 @@ class Ha(object):
tags['nosync'] = True
return tags
def notify_mpp_coordinator(self, event: str) -> None:
"""Send an event to the MPP coordinator.
:param event: the type of event for coordinator to parse.
"""
mpp_handler = self.state_handler.mpp_handler
if mpp_handler.is_worker():
coordinator = self.dcs.get_mpp_coordinator()
def notify_citus_coordinator(self, event: str) -> None:
if self.state_handler.citus_handler.is_worker():
coordinator = self.dcs.get_citus_coordinator()
if coordinator and coordinator.leader and coordinator.leader.conn_url:
try:
data = {'type': event,
'group': mpp_handler.group,
'group': self.state_handler.citus_handler.group(),
'leader': self.state_handler.name,
'timeout': self.dcs.ttl,
'cooldown': self.patroni.config['retry_timeout']}
timeout = self.dcs.ttl if event == 'before_demote' else 2
endpoint = 'citus' if mpp_handler.type == 'Citus' else 'mpp'
self.patroni.request(coordinator.leader.member, 'post', endpoint, data, timeout=timeout, retries=0)
self.patroni.request(coordinator.leader.member, 'post', 'citus', data, timeout=timeout, retries=0)
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)
def touch_member(self) -> bool:
@@ -370,9 +342,8 @@ class Ha(object):
tags = self.get_effective_tags()
if tags:
data['tags'] = tags
if self.state_handler.pending_restart_reason:
if self.state_handler.pending_restart:
data['pending_restart'] = True
data['pending_restart_reason'] = dict(self.state_handler.pending_restart_reason)
if self._async_executor.scheduled_action in (None, 'promote') \
and data['state'] in ['running', 'restarting', 'starting']:
try:
@@ -412,7 +383,7 @@ class Ha(object):
if ret:
new_state = (data['state'], {'master': 'primary'}.get(data['role'], data['role']))
if self._last_state != new_state and new_state == ('running', 'primary'):
self.notify_mpp_coordinator('after_promote')
self.notify_citus_coordinator('after_promote')
self._last_state = new_state
return ret
@@ -458,7 +429,7 @@ class Ha(object):
return ret or 'trying to bootstrap {0}'.format(msg)
# 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
can_bootstrap = self.state_handler.can_create_replica_without_replication_connection(create_replica_methods)
concurrent_bootstrap = self.cluster.initialize == ""
@@ -533,7 +504,7 @@ class Ha(object):
:returns: action message, describing what was performed.
"""
if self.has_lock() and self.update_lock():
timeout = global_config.primary_start_timeout
timeout = self.global_config.primary_start_timeout
if timeout == 0:
# We are requested to prefer failing over to restarting primary. But see first if there
# is anyone to fail over to.
@@ -610,14 +581,9 @@ class Ha(object):
: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 remote member, except when there is no standby leader in pause.
elif self.is_standby_cluster() \
and (cluster.leader and cluster.leader.name and cluster.leader.name == self.state_handler.name
or cluster.is_unlocked() and not self.is_paused()):
if self.is_standby_cluster() and (self.has_lock(False) or self.cluster.is_unlocked() and not self.is_paused()):
node_to_follow = self.get_remote_member()
# If replicatefrom tag is set, try to follow the node mentioned there, otherwise, follow the leader.
elif self.patroni.replicatefrom and self.patroni.replicatefrom != self.state_handler.name:
@@ -633,7 +599,7 @@ class Ha(object):
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.
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)})
return node_to_follow
@@ -695,11 +661,11 @@ class Ha(object):
def is_synchronous_mode(self) -> bool:
""":returns: `True` if synchronous replication is requested."""
return global_config.is_synchronous_mode
return self.global_config.is_synchronous_mode
def is_failsafe_mode(self) -> bool:
""":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 process_sync_replication(self) -> None:
"""Process synchronous standby beahvior.
@@ -724,14 +690,6 @@ class Ha(object):
current = CaseInsensitiveSet(sync.members)
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
sync_common = current & allow_promote
@@ -743,7 +701,7 @@ class Ha(object):
return logger.info('Synchronous replication key updated by someone else.')
# 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('*')
logger.warning("No standbys available!")
@@ -813,13 +771,13 @@ class Ha(object):
if cluster_history:
self.dcs.set_history_value('[]')
elif not cluster_history or cluster_history[-1][0] != primary_timeline - 1 or len(cluster_history[-1]) != 5:
cluster_history_dict: Dict[int, List[Any]] = {line[0]: list(line) for line in cluster_history}
cluster_history = {line[0]: line for line in cluster_history}
history: List[List[Any]] = list(map(list, self.state_handler.get_history(primary_timeline)))
if self.cluster.config:
history = history[-global_config.max_timelines_history:]
history = history[-self.cluster.config.max_timelines_history:]
for line in history:
# enrich current history with promotion timestamps stored in DCS
cluster_history_line = cluster_history_dict.get(line[0], [])
cluster_history_line = list(cluster_history.get(line[0], []))
if len(line) == 3 and len(cluster_history_line) >= 4 and cluster_history_line[1] == line[1]:
line.append(cluster_history_line[3])
if len(cluster_history_line) == 5:
@@ -860,7 +818,7 @@ class Ha(object):
self.state_handler.set_role('master')
self.process_sync_replication()
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
elif self.state_handler.role in ('master', 'promoted', 'primary'):
self.process_sync_replication()
@@ -874,13 +832,13 @@ class Ha(object):
# promotion until next cycle. TODO: trigger immediate retry of run_cycle
return 'Postponing promotion because synchronous replication state was updated by somebody else'
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())
if self.state_handler.role not in ('master', 'promoted', 'primary'):
# reset failsafe state when promote
self._failsafe.set_is_active(0)
def before_promote():
self.notify_mpp_coordinator('before_promote')
self.notify_citus_coordinator('before_promote')
with self._async_response:
self._async_response.reset()
@@ -931,26 +889,6 @@ class Ha(object):
return False
def check_failsafe_topology(self) -> bool:
"""Check whether we could continue to run as a primary by calling all members from the failsafe topology.
.. note::
If the ``/failsafe`` key contains invalid data or if the ``name`` of our node is missing in
the ``/failsafe`` key, we immediately give up and return ``False``.
We send the JSON document in the POST request with the following fields:
* ``name`` - the name of our node;
* ``conn_url`` - connection URL to the postgres, which is reachable from other nodes;
* ``api_url`` - connection URL to Patroni REST API on this node reachable from other nodes;
* ``slots`` - a :class:`dict` with replication slots that exist on the leader node, including the primary
itself with the last known LSN, because there could be a permanent physical slot on standby nodes.
Standby nodes are using information from the ``slots`` dict to advance position of permanent
replication slots while DCS is not accessible in order to avoid indefinite growth of ``pg_wal``.
:returns: ``True`` if all members from the ``/failsafe`` topology agree that this node could continue to
run as a ``primary``, or ``False`` if some of standby nodes are not accessible or don't agree.
"""
failsafe = self.dcs.failsafe
if not isinstance(failsafe, dict) or self.state_handler.name not in failsafe:
return False
@@ -960,10 +898,7 @@ class Ha(object):
'api_url': self.patroni.api.connection_string,
}
try:
data['slots'] = {
**self.state_handler.slots(),
slot_name_from_member_name(self.state_handler.name): self.state_handler.last_operation()
}
data['slots'] = self.state_handler.slots()
except Exception:
logger.exception('Exception when called state_handler.slots()')
members = [RemoteMember(name, {'api_url': url})
@@ -985,7 +920,28 @@ class Ha(object):
:returns True when node is lagging
"""
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 has_members_eligible_to_promote(self, members: List[Member], reference_lsn: int = 0,
fast_path: bool = False) -> bool:
ret = False
cluster_timeline = self.cluster.timeline
for st in self.fetch_nodes_statuses(members):
not_allowed_reason = st.failover_limitation()
if not_allowed_reason:
logger.info('Member %s is %s', st.member.name, not_allowed_reason)
elif fast_path:
return True
elif reference_lsn and st.wal_position < reference_lsn or \
not reference_lsn and self.is_lagging(st.wal_position):
logger.info('Member %s exceeds maximum replication lag', st.member.name)
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline):
logger.info('Timeline %s of member %s is behind the cluster timeline %s',
st.timeline, st.member.name, cluster_timeline)
else:
ret = True
return ret
def _is_healthiest_node(self, members: Collection[Member], check_replication_lag: bool = True) -> bool:
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
@@ -1020,15 +976,6 @@ class Ha(object):
if not self.sync_mode_is_active() or not self.cluster.sync.leader_matches(st.member.name):
return False
logger.info('Ignoring the former leader being ahead of us')
if my_wal_position == st.wal_position and self.patroni.failover_priority < st.failover_priority:
# There's a higher priority non-lagging replica
logger.info(
'%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,
)
return False
return True
def is_failover_possible(self, *, cluster_lsn: int = 0, exclude_failover_candidate: bool = False) -> bool:
@@ -1048,21 +995,7 @@ class Ha(object):
elif not candidates:
logger.warning('%s: candidates list is empty', action)
ret = False
cluster_timeline = self.cluster.timeline
for st in self.fetch_nodes_statuses(candidates):
not_allowed_reason = st.failover_limitation()
if not_allowed_reason:
logger.info('Member %s is %s', st.member.name, not_allowed_reason)
elif cluster_lsn and st.wal_position < cluster_lsn or \
not cluster_lsn and self.is_lagging(st.wal_position):
logger.info('Member %s exceeds maximum replication lag', st.member.name)
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline):
logger.info('Timeline %s of member %s is behind the cluster timeline %s',
st.timeline, st.member.name, cluster_timeline)
else:
ret = True
return ret
return self.has_members_eligible_to_promote(candidates, cluster_lsn)
def manual_failover_process_no_leader(self) -> Optional[bool]:
"""Handles manual failover/switchover when the old leader already stepped down.
@@ -1111,7 +1044,7 @@ class Ha(object):
return False
# try to pick some other members for switchover and check that they are healthy
if failover.leader:
if failover.is_switchover:
if self.state_handler.name == failover.leader: # I was the leader
# exclude desired member which is unhealthy if it was specified
if self.is_failover_possible(exclude_failover_candidate=bool(failover.candidate)):
@@ -1168,7 +1101,7 @@ class Ha(object):
if self.cluster.failover:
# When doing a switchover in synchronous mode only synchronous nodes and former leader are allowed to race
if self.cluster.failover.leader and self.sync_mode_is_active() \
if self.cluster.failover.is_switchover and self.sync_mode_is_active() \
and not self.cluster.sync.matches(self.state_handler.name, True):
return False
return self.manual_failover_process_no_leader() or False
@@ -1238,23 +1171,22 @@ class Ha(object):
status = {'released': False}
def on_shutdown(checkpoint_location: int, prev_location: int) -> None:
def on_shutdown(checkpoint_location: int) -> None:
# Postmaster is still running, but pg_control already reports clean "shut down".
# It could happen if Postgres is still archiving the backlog of WAL files.
# If we know that there are replicas that received the shutdown checkpoint
# location, we can remove the leader key and allow them to start leader race.
time.sleep(1) # give replicas some more time to catch up
if self.is_failover_possible(cluster_lsn=checkpoint_location):
self.state_handler.set_role('demoted')
with self._async_executor:
self.release_leader_key_voluntarily(prev_location)
self.release_leader_key_voluntarily(checkpoint_location)
status['released'] = True
def before_shutdown() -> None:
if self.state_handler.mpp_handler.is_coordinator():
self.state_handler.mpp_handler.on_demote()
if self.state_handler.citus_handler.is_coordinator():
self.state_handler.citus_handler.on_demote()
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']),
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None,
@@ -1498,7 +1430,7 @@ class Ha(object):
if postgres_version and postgres_version_to_int(postgres_version) <= int(self.state_handler.server_version):
reason_to_cancel = "postgres version mismatch"
if pending_restart and not self.state_handler.pending_restart_reason:
if pending_restart and not self.state_handler.pending_restart:
reason_to_cancel = "pending restart flag is not set"
if not reason_to_cancel:
@@ -1552,14 +1484,14 @@ class Ha(object):
# 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.
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)
def before_shutdown() -> None:
self.notify_mpp_coordinator('before_demote')
self.notify_citus_coordinator('before_demote')
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.
do_restart = functools.partial(self.state_handler.restart, timeout, self._async_executor.critical_task,
@@ -1616,7 +1548,7 @@ class Ha(object):
"""Figure out what to do with the task AsyncExecutor is performing."""
if self.has_lock() and self.update_lock():
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():
logger.info("Demoting self because crash recovery is taking too long")
self.state_handler.cancellable.cancel(True)
@@ -1701,7 +1633,7 @@ class Ha(object):
self.set_is_leader(True)
if self.is_synchronous_mode():
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.load_cluster_from_dcs()
@@ -1724,7 +1656,7 @@ class Ha(object):
self.demote('immediate-nolock')
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()
if time_left <= 0:
@@ -1757,8 +1689,8 @@ class Ha(object):
try:
try:
self.load_cluster_from_dcs()
global_config.update(self.cluster)
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni)
self.global_config = self.patroni.config.get_global_config(self.cluster)
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni.nofailover, self.global_config)
except Exception:
self.state_handler.reset_cluster_info_state(None)
raise
@@ -1778,10 +1710,10 @@ class Ha(object):
self.touch_member()
# 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)
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.cluster = self.dcs.get_cluster()
@@ -1862,9 +1794,10 @@ class Ha(object):
logger.fatal('system ID mismatch, node %s belongs to a different cluster: %s != %s',
self.state_handler.name, self.cluster.initialize, data_sysid)
sys.exit(1)
elif self.cluster.is_unlocked() and not self.is_paused() and not self.state_handler.cb_called:
elif self.cluster.is_unlocked() and not self.is_paused():
# "bootstrap", but data directory is not empty
if self.state_handler.is_running() and not self.state_handler.is_primary():
if not self.state_handler.cb_called and self.state_handler.is_running() \
and not self.state_handler.is_primary():
self._join_aborted = True
logger.error('No initialize key in DCS and PostgreSQL is running as replica, aborting start')
logger.error('Please first start Patroni on the node running as primary')
@@ -1914,7 +1847,7 @@ class Ha(object):
if not is_promoting and create_slots and self.cluster.leader:
err = self._async_executor.try_run_async('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:
ret = 'Copying logical slots {0} from the primary'.format(create_slots)
return ret
@@ -1970,7 +1903,10 @@ class Ha(object):
cluster = self._failsafe.update_cluster(self.cluster)\
if self.is_failsafe_mode() and not self.is_leader() else self.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
return [] if self.failsafe_is_active() else slots
@@ -1998,21 +1934,21 @@ class Ha(object):
status = {'deleted': False}
def _on_shutdown(checkpoint_location: int, prev_location: int) -> None:
def _on_shutdown(checkpoint_location: int) -> None:
if self.is_leader():
# Postmaster is still running, but pg_control already reports clean "shut down".
# It could happen if Postgres is still archiving the backlog of WAL files.
# If we know that there are replicas that received the shutdown checkpoint
# location, we can remove the leader key and allow them to start leader race.
time.sleep(1) # give replicas some more time to catch up
if self.is_failover_possible(cluster_lsn=checkpoint_location):
self.dcs.delete_leader(self.cluster.leader, prev_location)
self.dcs.delete_leader(self.cluster.leader, checkpoint_location)
status['deleted'] = True
else:
self.dcs.write_leader_optime(prev_location)
self.dcs.write_leader_optime(checkpoint_location)
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
before_shutdown = _before_shutdown if self.is_leader() else None
@@ -2054,7 +1990,7 @@ class Ha(object):
config or cluster.config.data.
"""
data: Dict[str, Any] = {}
cluster_params = global_config.get_standby_cluster_config()
cluster_params = self.global_config.get_standby_cluster_config()
if cluster_params:
data.update({k: v for k, v in cluster_params.items() if k in RemoteMember.ALLOWED_KEYS})
@@ -2086,7 +2022,7 @@ class Ha(object):
def is_eligible(node: Member) -> bool:
# in synchronous mode we allow failover (not switchover!) to async node
if self.sync_mode_is_active() and not self.cluster.sync.matches(node.name)\
and not (failover and not failover.leader):
and not (failover and failover.is_failover):
return False
# Don't spend time on "nofailover" nodes checking.
# We also don't need nodes which we can't query with the api in the list.
+29 -189
View File
@@ -9,15 +9,12 @@ import sys
from copy import deepcopy
from logging.handlers import RotatingFileHandler
from patroni.utils import deep_compare
from queue import Queue, Full
from threading import Lock, Thread
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from .utils import deep_compare
type_logformat = Union[List[Union[str, Dict[str, Any], Any]], str, Any]
_LOGGER = logging.getLogger(__name__)
@@ -160,7 +157,6 @@ class PatroniLogger(Thread):
.. seealso::
:class:`QueueHandler`: object used for enqueueing messages in-memory.
:cvar DEFAULT_TYPE: default type of log format (``plain``).
:cvar DEFAULT_LEVEL: default logging level (``INFO``).
:cvar DEFAULT_TRACEBACK_LEVEL: default traceback logging level (``ERROR``).
:cvar DEFAULT_FORMAT: default format of log messages (``%(asctime)s %(levelname)s: %(message)s``).
@@ -173,7 +169,6 @@ class PatroniLogger(Thread):
:ivar log_handler_lock: lock used to modify ``log_handler``.
"""
DEFAULT_TYPE = 'plain'
DEFAULT_LEVEL = 'INFO'
DEFAULT_TRACEBACK_LEVEL = 'ERROR'
DEFAULT_FORMAT = '%(asctime)s %(levelname)s: %(message)s'
@@ -207,186 +202,28 @@ class PatroniLogger(Thread):
self._proxy_handler = ProxyHandler(self)
self._root_logger.addHandler(self._proxy_handler)
def update_loggers(self, config: Dict[str, Any]) -> None:
"""Configure custom loggers' log levels.
def update_loggers(self) -> None:
"""Configure loggers' log level as defined in ``log.loggers`` section of Patroni configuration.
.. note::
It creates logger objects that are not defined yet in the log manager.
:param config: :class:`dict` object with custom loggers configuration, is set either from:
* ``log.loggers`` section of Patroni configuration; or
* from the method that is trying to make sure that the node name
isn't duplicated (to silence annoying ``urllib3`` WARNING's).
:Example:
.. code-block:: python
update_loggers({'urllib3.connectionpool': 'WARNING'})
"""
loggers = deepcopy(config)
loggers = deepcopy((self._config or {}).get('loggers') or {})
for name, logger in self._root_logger.manager.loggerDict.items():
# ``Placeholder`` is a node in the log manager for which no logger has been defined. We are interested only
# in the ones that were defined
if not isinstance(logger, logging.PlaceHolder):
# if this logger is present in *config*, use the configured level, otherwise
# use ``logging.NOTSET``, which means it will inherit the level
# from any parent node up to the root for which log level is defined.
# if this logger is present in ``log.loggers`` Patroni configuration, use the configured level,
# otherwise use ``logging.NOTSET``, which means it will inherit the level from any parent node up to
# the root for which log level is defined.
level = loggers.pop(name, logging.NOTSET)
logger.setLevel(level)
# define loggers that do not exist yet and set level as configured in the *config*
# define loggers that do not exist yet and set level as configured in ``log.loggers`` section of configuration.
for name, level in loggers.items():
logger = self._root_logger.manager.getLogger(name)
logger.setLevel(level)
def _is_config_changed(self, config: Dict[str, Any]) -> bool:
"""Checks if the given config is different from the current one.
:param config: ``log`` section from Patroni configuration.
:returns: ``True`` if the config is changed, ``False`` otherwise.
"""
old_config = self._config or {}
oldlogtype = old_config.get('type', PatroniLogger.DEFAULT_TYPE)
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
oldlogformat: type_logformat = old_config.get('format', PatroniLogger.DEFAULT_FORMAT)
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
olddateformat = old_config.get('dateformat') or None
dateformat = config.get('dateformat') or None # Convert empty string to `None`
old_static_fields = old_config.get('static_fields', {})
static_fields = config.get('static_fields', {})
old_log_config = {
'type': oldlogtype,
'format': oldlogformat,
'dateformat': olddateformat,
'static_fields': old_static_fields
}
log_config = {
'type': logtype,
'format': logformat,
'dateformat': dateformat,
'static_fields': static_fields
}
return not deep_compare(old_log_config, log_config)
def _get_plain_formatter(self, logformat: type_logformat, dateformat: Optional[str]) -> logging.Formatter:
"""Returns a logging formatter with the specified format and date format.
.. note::
If the log format isn't a string, prints a warning message and uses the default log format instead.
:param logformat: The format of the log messages.
:param dateformat: The format of the timestamp in the log messages.
:returns: A logging formatter object that can be used to format log records.
"""
if not isinstance(logformat, str):
_LOGGER.warning('Expected log format to be a string when log type is plain, but got "%s"', type(logformat))
logformat = PatroniLogger.DEFAULT_FORMAT
return logging.Formatter(logformat, dateformat)
def _get_json_formatter(self, logformat: type_logformat, dateformat: Optional[str],
static_fields: Dict[str, Any]) -> logging.Formatter:
"""Returns a logging formatter that outputs JSON formatted messages.
.. note::
If :mod:`pythonjsonlogger` library is not installed, prints an error message and returns
a plain log formatter instead.
:param logformat: Specifies the log fields and their key names in the JSON log message.
:param dateformat: The format of the timestamp in the log messages.
:param static_fields: A dictionary of static fields that are added to every log message.
:returns: A logging formatter object that can be used to format log records as JSON strings.
"""
if isinstance(logformat, str):
jsonformat = logformat
rename_fields = {}
elif isinstance(logformat, list):
log_fields: List[str] = []
rename_fields: Dict[str, str] = {}
for field in logformat:
if isinstance(field, str):
log_fields.append(field)
elif isinstance(field, dict):
for original_field, renamed_field in field.items():
if isinstance(renamed_field, str):
log_fields.append(original_field)
rename_fields[original_field] = renamed_field
else:
_LOGGER.warning(
'Expected renamed log field to be a string, but got "%s"',
type(renamed_field)
)
else:
_LOGGER.warning(
'Expected each item of log format to be a string or dictionary, but got "%s"',
type(field)
)
if len(log_fields) > 0:
jsonformat = ' '.join([f'%({field})s' for field in log_fields])
else:
jsonformat = PatroniLogger.DEFAULT_FORMAT
else:
jsonformat = PatroniLogger.DEFAULT_FORMAT
rename_fields = {}
_LOGGER.warning('Expected log format to be a string or a list, but got "%s"', type(logformat))
try:
from pythonjsonlogger import jsonlogger
return jsonlogger.JsonFormatter(
jsonformat,
dateformat,
rename_fields=rename_fields,
static_fields=static_fields
)
except ImportError as e:
_LOGGER.error('Failed to import "python-json-logger" library: %r. Falling back to the plain logger', e)
except Exception as e:
_LOGGER.error('Failed to initialize JsonFormatter: %r. Falling back to the plain logger', e)
return self._get_plain_formatter(jsonformat, dateformat)
def _get_formatter(self, config: Dict[str, Any]) -> logging.Formatter:
"""Returns a logging formatter based on the type of logger in the given configuration.
:param config: ``log`` section from Patroni configuration.
:returns: A :class:`logging.Formatter` object that can be used to format log records.
"""
logtype = config.get('type', PatroniLogger.DEFAULT_TYPE)
logformat: type_logformat = config.get('format', PatroniLogger.DEFAULT_FORMAT)
dateformat = config.get('dateformat') or None # Convert empty string to `None`
static_fields = config.get('static_fields', {})
if dateformat is not None and not isinstance(dateformat, str):
_LOGGER.warning('Expected log dateformat to be a string, but got "%s"', type(dateformat))
dateformat = None
if logtype == 'json':
formatter = self._get_json_formatter(logformat, dateformat, static_fields)
else:
formatter = self._get_plain_formatter(logformat, dateformat)
return formatter
def reload_config(self, config: Dict[str, Any]) -> None:
"""Apply log related configuration.
@@ -407,34 +244,37 @@ class PatroniLogger(Thread):
# show stack traces as ``ERROR`` log messages
logging.Logger.exception = error_exception
handler = self.log_handler
new_handler = None
if 'dir' in config:
if not isinstance(handler, RotatingFileHandler):
handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
max_file_size = int(config.get('file_size', 25000000))
handler.maxBytes = max_file_size # pyright: ignore [reportAttributeAccessIssue]
if not isinstance(self.log_handler, RotatingFileHandler):
new_handler = RotatingFileHandler(os.path.join(config['dir'], __name__))
handler = new_handler or self.log_handler
if TYPE_CHECKING: # pragma: no cover
assert isinstance(handler, RotatingFileHandler)
handler.maxBytes = int(config.get('file_size', 25000000)) # pyright: ignore [reportGeneralTypeIssues]
handler.backupCount = int(config.get('file_num', 4))
# we can't use `if not isinstance(handler, logging.StreamHandler)` below,
# because RotatingFileHandler is a child of StreamHandler!!!
elif handler is None or isinstance(handler, RotatingFileHandler):
handler = logging.StreamHandler()
else:
if self.log_handler is None or isinstance(self.log_handler, RotatingFileHandler):
new_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:
formatter = self._get_formatter(config)
handler.setFormatter(formatter)
olddateformat = (self._config or {}).get('dateformat') or None
dateformat = config.get('dateformat') or None # Convert empty string to `None`
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:
if self.log_handler:
self._old_handlers.append(self.log_handler)
self.log_handler = handler
self.log_handler = new_handler
self._config = config.copy()
self.update_loggers(config.get('loggers') or {})
self.update_loggers()
def _close_old_handlers(self) -> None:
"""Close old log handlers.
+93
View File
@@ -0,0 +1,93 @@
from enum import Enum
from typing import Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
import datetime
from .dcs import Cluster
from .ha import Patroni
from .utils import ParseScheduleErrors
from .utils import parse_schedule
class ManualFailoverPrecheckStatus(Enum):
FAILOVER_NO_CANDIDATE = ('Failover could be performed only to a specific candidate', 400)
SWITCHOVER_NO_LEADER = ('Switchover could be performed only from a specific leader', 400)
SCHEDULED_FAILOVER = ("Failover can't be scheduled", 400)
SCHEDULED_SWITCHOVER_PAUSE = ("Can't schedule switchover in the paused state", 400)
SWITCHOVER_PAUSE_NO_CANDIDATE = ('Switchover is possible only to a specific candidate in a paused state', 400)
SWITCHOVER_TO_LEADER = ('Switchover target and source are the same', 400)
CLUSTER_NO_LEADER = ('Cluster {cluster_name} has no leader', 412)
LEADER_NOT_MEMBER = ('Member {leader} is not the leader of cluster {cluster_name}', 412)
CANDIDATE_NOT_SYNC_STANDBY = ('candidate name does not match with sync_standby', 412)
NO_SYNC_CANDIDATE = ('{action} is not possible: can not find sync_standby', 412)
ONLY_LEADER = ('{action} is not possible: cluster does not have members except leader', 412)
CANDIDATE_NOT_MEMEBER = ('Member {candidate} does not exist in cluster {cluster_name} or is tagged as nofailover',
412)
NO_GOOD_CANDIDATES = ('{action} is not possible: no good candidates have been found', 412)
CHECK_PASSED = ('', None)
class ManualFailover(object):
def __init__(self, action: str, cluster: 'Cluster',
leader: Optional[str], candidate: Optional[str], scheduled: Optional[str],
paused: bool = False, sync_mode: bool = False, patroni_obj: Optional['Patroni'] = None) -> None:
self.action = action
self.cluster = cluster
self.leader = leader
self.candidate = candidate
self.scheduled = scheduled
self.paused = paused
self.sync_mode = sync_mode
self.patroni = patroni_obj
def parse_scheduled(self) -> Tuple[Optional['ParseScheduleErrors'], Optional['datetime.datetime']]:
return parse_schedule(self.scheduled)
def run_precheck(self) -> ManualFailoverPrecheckStatus:
if self.action == 'failover' and not self.candidate:
return ManualFailoverPrecheckStatus.FAILOVER_NO_CANDIDATE
elif self.action == 'switchover' and not self.leader:
return ManualFailoverPrecheckStatus.SWITCHOVER_NO_LEADER
if self.scheduled:
if self.action == 'failover':
return ManualFailoverPrecheckStatus.SCHEDULED_FAILOVER
elif self.paused:
return ManualFailoverPrecheckStatus.SCHEDULED_SWITCHOVER_PAUSE
if self.paused and not self.candidate:
return ManualFailoverPrecheckStatus.SWITCHOVER_PAUSE_NO_CANDIDATE
if self.leader == self.candidate:
return ManualFailoverPrecheckStatus.SWITCHOVER_TO_LEADER
if self.action == 'switchover':
if self.cluster.leader is None or not self.cluster.leader.name:
return ManualFailoverPrecheckStatus.CLUSTER_NO_LEADER
if self.cluster.leader.name != self.leader:
return ManualFailoverPrecheckStatus.LEADER_NOT_MEMBER
if self.candidate:
if self.action == 'switchover' and self.sync_mode and not self.cluster.sync.matches(self.candidate):
return ManualFailoverPrecheckStatus.CANDIDATE_NOT_SYNC_STANDBY
members = [m for m in self.cluster.members if m.name == self.candidate]
if not members:
return ManualFailoverPrecheckStatus.CANDIDATE_NOT_MEMEBER
elif self.sync_mode:
members = [m for m in self.cluster.members if self.cluster.sync.matches(m.name)]
if not members:
return ManualFailoverPrecheckStatus.NO_SYNC_CANDIDATE
else:
members = [m for m in self.cluster.members if not self.cluster.leader or m.name != self.cluster.leader.name and m.api_url]
if not members:
return ManualFailoverPrecheckStatus.ONLY_LEADER
if self.patroni and not self.patroni.ha.has_members_eligible_to_promote(members, fast_path=True):
return ManualFailoverPrecheckStatus.NO_GOOD_CANDIDATES
return ManualFailoverPrecheckStatus.CHECK_PASSED
+67 -103
View File
@@ -19,22 +19,22 @@ from .callback_executor import CallbackAction, CallbackExecutor
from .cancellable import CancellableSubprocess
from .config import ConfigHandler, mtime
from .connection import ConnectionPool, get_connection_cursor
from .citus import CitusHandler
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
from .mpp import AbstractMPP
from .postmaster import PostmasterProcess
from .slots import SlotsHandler
from .sync import SyncHandler
from .. import global_config, psycopg
from .. import psycopg
from ..async_executor import CriticalTask
from ..collections import CaseInsensitiveSet, CaseInsensitiveDict, EMPTY_DICT
from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION
from ..collections import CaseInsensitiveSet
from ..dcs import Cluster, Leader, Member
from ..exceptions import PostgresConnectionException
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
from ..tags import Tags
if TYPE_CHECKING: # pragma: no cover
from psycopg import Connection as Connection3, Cursor
from psycopg2 import connection as connection3, cursor
from ..config import GlobalConfig
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_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.scope: str = config['scope']
self._data_dir: str = config['data_dir']
@@ -73,14 +73,15 @@ class Postgresql(object):
self.connection_string: str
self.proxy_url: Optional[str]
self._major_version = self.get_major_version()
self._global_config = None
self._state_lock = Lock()
self.set_state('stopped')
self._pending_restart_reason = CaseInsensitiveDict()
self._pending_restart = False
self.connection_pool = ConnectionPool()
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.check_directories()
@@ -111,37 +112,24 @@ class Postgresql(object):
self._state_entry_timestamp = 0
self._cluster_info_state = {}
self._has_permanent_slots = True
self._has_permanent_logical_slots = True
self._enforce_hot_standby_feedback = False
self._cached_replica_timeline = None
# Last known running process
self._postmaster_proc = None
self._available_gucs = None
if self.is_running():
# If we found postmaster process we need to figure out whether postgres is accepting connections
self.set_state('starting')
self.check_startup_state_changed()
if self.state == 'running': # we are "joining" already running postgres
# we know that PostgreSQL is accepting connections and can read some GUC's from pg_settings
self.config.load_current_server_parameters()
if self.is_running(): # we are "joining" already running postgres
self.set_state('running')
self.set_role('master' if self.is_primary() else 'replica')
# postpone writing postgresql.conf for 12+ because recovery parameters are not yet known
if self.major_version < 120000 or self.is_primary():
self.config.write_postgresql_conf()
hba_saved = self.config.replace_pg_hba()
ident_saved = self.config.replace_pg_ident()
if self.major_version < 120000 or self.role in ('master', 'primary'):
# If PostgreSQL is running as a primary or we run PostgreSQL that is older than 12 we can
# call reload_config() once again (the first call happened in the ConfigHandler constructor),
# so that it can figure out if config files should be updated and pg_ctl reload executed.
self.config.reload_config(config, sighup=bool(hba_saved or ident_saved))
elif hba_saved or ident_saved:
if hba_saved or ident_saved:
self.reload()
elif not self.is_running() and self.role in ('master', 'primary'):
elif self.role in ('master', 'primary'):
self.set_role('demoted')
@property
@@ -186,11 +174,6 @@ class Postgresql(object):
""":returns: `True` if Postgres version supports more than one synchronous node."""
return self._major_version >= 90600
@property
def can_advance_slots(self) -> bool:
"""``True`` if :attr:``major_version`` is greater than 110000."""
return self.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION
@property
def cluster_info_query(self) -> str:
"""Returns the monitoring query with a fixed number of fields.
@@ -218,16 +201,15 @@ class Postgresql(object):
"FROM pg_catalog.pg_stat_get_wal_senders() w,"
" pg_catalog.pg_stat_get_activity(w.pid)"
" 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")
if self._major_version >= 90600:
extra = ("pg_catalog.current_setting('restore_command')" if self._major_version >= 120000 else "NULL") +\
", " + ("(SELECT pg_catalog.json_agg(s.*) FROM (SELECT slot_name, slot_type as type, datoid::bigint, "
"plugin, catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"
" AS confirmed_flush_lsn, pg_catalog.pg_wal_lsn_diff(restart_lsn, '0/0')::bigint"
" AS restart_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"
if self._has_permanent_slots and self.can_advance_slots else "NULL") + extra
" AS confirmed_flush_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"
if self._has_permanent_logical_slots and self._major_version >= 110000 else "NULL") + extra
extra = (", CASE WHEN latest_end_lsn IS NULL THEN NULL ELSE received_tli END,"
" slot_name, conninfo, status, {0} FROM pg_catalog.pg_stat_get_wal_receiver()").format(extra)
if self.role == 'standby_leader':
@@ -242,9 +224,7 @@ class Postgresql(object):
@property
def available_gucs(self) -> CaseInsensitiveSet:
"""GUCs available in this Postgres server."""
if not self._available_gucs:
self._available_gucs = self._get_gucs()
return self._available_gucs
return self._get_gucs()
def _version_file_exists(self) -> bool:
return not self.data_directory_empty() and os.path.isfile(self._version_file)
@@ -272,7 +252,7 @@ class Postgresql(object):
:returns: path to Postgres binary named *cmd*.
"""
return os.path.join(self._bin_dir, (self.config.get('bin_name', {}) or EMPTY_DICT).get(cmd, cmd))
return os.path.join(self._bin_dir, (self.config.get('bin_name', {}) or {}).get(cmd, cmd))
def pg_ctl(self, cmd: str, *args: str, **kwargs: Any) -> bool:
"""Builds and executes pg_ctl command
@@ -321,22 +301,11 @@ class Postgresql(object):
self._is_leader_retry.deadline = self.retry.deadline = config['retry_timeout'] / 2.0
@property
def pending_restart_reason(self) -> CaseInsensitiveDict:
"""Get :attr:`_pending_restart_reason` value.
def pending_restart(self) -> bool:
return self._pending_restart
:attr:`_pending_restart_reason` is a :class:`CaseInsensitiveDict` object of the PG parameters that are
causing pending restart state. Every key is a parameter name, value - a dictionary containing the old
and the new value (see :func:`~patroni.postgresql.config.get_param_diff`).
"""
return self._pending_restart_reason
def set_pending_restart_reason(self, diff_dict: CaseInsensitiveDict) -> None:
"""Set new or update current :attr:`_pending_restart_reason`.
:param diff_dict: :class:``CaseInsensitiveDict`` object with the parameters that are causing pending restart
state with the diff of their values. Used to reset/update the :attr:`_pending_restart_reason`.
"""
self._pending_restart_reason = diff_dict
def set_pending_restart(self, value: bool) -> None:
self._pending_restart = value
@property
def sysid(self) -> str:
@@ -414,7 +383,7 @@ class Postgresql(object):
return data_directory_is_empty(self._data_dir)
def replica_method_options(self, method: str) -> Dict[str, Any]:
return deepcopy(self.config.get(method, {}) or EMPTY_DICT.copy())
return deepcopy(self.config.get(method, {}) or {})
def replica_method_can_work_without_replication_connection(self, method: str) -> bool:
return method != 'basebackup' and bool(self.replica_method_options(method).get('no_master')
@@ -440,30 +409,43 @@ class Postgresql(object):
self.config.write_postgresql_conf()
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.
.. 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 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 = {}
if not tags:
if global_config:
self._global_config = global_config
if not self._global_config:
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
self._has_permanent_logical_slots = False
self.set_enforce_hot_standby_feedback(False)
elif cluster and cluster.config and cluster.config.modify_version:
self._has_permanent_logical_slots =\
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version)
if cluster and cluster.config and cluster.config.modify_version:
# 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.
self.set_enforce_hot_standby_feedback(not global_config.is_standby_cluster and self.can_advance_slots
and cluster.should_enforce_hot_standby_feedback(self, tags))
self._has_permanent_slots = cluster.has_permanent_slots(self, tags)
self.set_enforce_hot_standby_feedback(
self._has_permanent_logical_slots
or cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
def _cluster_info_state_get(self, name: str) -> Optional[Any]:
if not self._cluster_info_state:
@@ -474,7 +456,7 @@ class Postgresql(object):
'received_tli', 'slot_name', 'conninfo', 'receiver_state',
'restore_command', 'slots', 'synchronous_commit',
'synchronous_standby_names', 'pg_stat_replication'], result))
if self._has_permanent_slots and self.can_advance_slots:
if self._has_permanent_logical_slots:
cluster_info_state['slots'] =\
self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
self._cluster_info_state = cluster_info_state
@@ -586,20 +568,17 @@ class Postgresql(object):
r'lsn: ([0-9A-Fa-f]+/[0-9A-Fa-f]+), prev ([0-9A-Fa-f]+/[0-9A-Fa-f]+), '
r'.*?desc: (.+)', out.decode('utf-8'))
if match:
return match.group(1), match.group(2), match.group(3), match.group(4)
return match.groups()
return None, None, None, None
def _checkpoint_locations_from_controldata(self, data: Dict[str, str]) -> Optional[Tuple[int, int]]:
"""Get shutdown checkpoint location.
def latest_checkpoint_location(self) -> Optional[int]:
"""Returns checkpoint location for the cleanly shut down primary.
But, if we know that the checkpoint was written to the new WAL
due to the archive_mode=on, we will return the LSN of prev wal record (SWITCH)."""
:param data: :class:`dict` object with values returned by `pg_controldata` tool.
:returns: a tuple of checkpoint LSN for the cleanly shut down primary, and LSN of prev wal record (SWITCH)
if we know that the checkpoint was written to the new WAL file due to the archive_mode=on.
"""
data = self.controldata()
timeline = data.get("Latest checkpoint's TimeLineID")
lsn = checkpoint_lsn = data.get('Latest checkpoint location')
prev_lsn = None
if data.get('Database cluster state') == 'shut down' and lsn and timeline and checkpoint_lsn:
try:
checkpoint_lsn = parse_lsn(checkpoint_lsn)
@@ -610,26 +589,13 @@ class Postgresql(object):
_, lsn, _, desc = self.parse_wal_record(timeline, prev)
prev = parse_lsn(prev)
# If the cluster is shutdown with archive_mode=on, WAL is switched before writing the checkpoint.
# In this case we want to take the LSN of previous record (SWITCH) as the last known WAL location.
# In this case we want to take the LSN of previous record (switch) as the last known WAL location.
if lsn and parse_lsn(lsn) == prev and str(desc).strip() in ('xlog switch', 'SWITCH'):
prev_lsn = prev
return prev
except Exception as e:
logger.error('Exception when parsing WAL pg_%sdump output: %r', self.wal_name, e)
if isinstance(checkpoint_lsn, int):
return checkpoint_lsn, (prev_lsn or checkpoint_lsn)
def latest_checkpoint_location(self) -> Optional[int]:
"""Get shutdown checkpoint location.
.. note::
In case if checkpoint was written to the new WAL file due to the archive_mode=on
we return LSN of the previous wal record (SWITCH).
:returns: checkpoint LSN for the cleanly shut down primary.
"""
checkpoint_locations = self._checkpoint_locations_from_controldata(self.controldata())
if checkpoint_locations:
return checkpoint_locations[1]
return checkpoint_lsn
def is_running(self) -> Optional[PostmasterProcess]:
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
@@ -738,7 +704,7 @@ class Postgresql(object):
self.set_role(role or self.get_postgres_role_from_data_directory())
self.set_state('starting')
self.set_pending_restart_reason(CaseInsensitiveDict())
self._pending_restart = False
try:
if not self.ensure_major_version_is_known():
@@ -815,7 +781,7 @@ class Postgresql(object):
return 'not accessible or not healty'
def stop(self, mode: str = 'fast', block_callbacks: bool = False, checkpoint: Optional[bool] = None,
on_safepoint: Optional[Callable[..., Any]] = None, on_shutdown: Optional[Callable[[int, int], Any]] = None,
on_safepoint: Optional[Callable[..., Any]] = None, on_shutdown: Optional[Callable[[int], Any]] = None,
before_shutdown: Optional[Callable[..., Any]] = None, stop_timeout: Optional[int] = None) -> bool:
"""Stop PostgreSQL
@@ -845,7 +811,7 @@ class Postgresql(object):
return success
def _do_stop(self, mode: str, block_callbacks: bool, checkpoint: bool,
on_safepoint: Optional[Callable[..., Any]], on_shutdown: Optional[Callable[[int, int], Any]],
on_safepoint: Optional[Callable[..., Any]], on_shutdown: Optional[Callable[..., Any]],
before_shutdown: Optional[Callable[..., Any]], stop_timeout: Optional[int]) -> Tuple[bool, bool]:
postmaster = self.is_running()
if not postmaster:
@@ -885,9 +851,7 @@ class Postgresql(object):
while postmaster.is_running():
data = self.controldata()
if data.get('Database cluster state', '') == 'shut down':
checkpoint_locations = self._checkpoint_locations_from_controldata(data)
if checkpoint_locations:
on_shutdown(*checkpoint_locations)
on_shutdown(self.latest_checkpoint_location())
break
elif data.get('Database cluster state', '').startswith('shut down'): # shut down in recovery
break
@@ -1059,7 +1023,7 @@ class Postgresql(object):
return None, None
@contextmanager
def get_replication_connection_cursor(self, host: Optional[str] = None, port: Union[int, str] = 5432,
def get_replication_connection_cursor(self, host: Optional[str] = None, port: int = 5432,
**kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
conn_kwargs = self.config.replication.copy()
conn_kwargs.update(host=host, port=int(port) if port else None, user=conn_kwargs.pop('username'),
@@ -1208,7 +1172,7 @@ class Postgresql(object):
before_promote()
self.slots_handler.on_promote()
self.mpp_handler.schedule_cache_rebuild()
self.citus_handler.schedule_cache_rebuild()
ret = self.pg_ctl('promote', '-W')
if ret:
@@ -1355,7 +1319,7 @@ class Postgresql(object):
"""
self.ensure_major_version_is_known()
self.slots_handler.schedule()
self.mpp_handler.schedule_cache_rebuild()
self.citus_handler.schedule_cache_rebuild()
self._sysid = ''
def _get_gucs(self) -> CaseInsensitiveSet:
@@ -1,59 +0,0 @@
import logging
import sys
from typing import Iterator
logger = logging.getLogger(__name__)
if sys.version_info < (3, 9): # pragma: no cover
from pathlib import Path
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)
+7 -49
View File
@@ -7,7 +7,6 @@ import time
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, Member, RemoteMember
from ..psycopg import quote_ident, quote_literal
from ..utils import deep_compare, unquote
@@ -101,11 +100,10 @@ class Bootstrap(object):
user_options.append('--{0}'.format(opt))
elif isinstance(opt, dict):
keys = list(opt.keys())
if len(keys) == 1 and isinstance(opt[keys[0]], str) and option_is_allowed(keys[0]):
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
else:
if len(keys) != 1 or not isinstance(opt[keys[0]], str) or not option_is_allowed(keys[0]):
error_handler('Error when parsing {0} key-value option {1}: only one key-value is allowed'
' and value should be a string'.format(tool, opt[keys[0]]))
user_options.append('--{0}={1}'.format(keys[0], unquote(opt[keys[0]])))
else:
error_handler('Error when parsing {0} option {1}: value should be string value'
' or a single key-value pair'.format(tool, opt))
@@ -147,52 +145,15 @@ class Bootstrap(object):
# make sure there is no trigger file or postgres will be automatically promoted
trigger_file = self._postgresql.config.triggerfile_good_name
trigger_file = (self._postgresql.config.get('recovery_conf') or EMPTY_DICT).get(trigger_file) or 'promote'
trigger_file = (self._postgresql.config.get('recovery_conf') or {}).get(trigger_file) or 'promote'
trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
if os.path.exists(trigger_file):
os.unlink(trigger_file)
def _custom_bootstrap(self, config: Any) -> bool:
"""Bootstrap a fresh Patroni cluster using a custom method provided by the user.
:param config: configuration used for running a custom bootstrap method. It comes from the Patroni YAML file,
so it is expected to be a :class:`dict`.
.. note::
*config* must contain a ``command`` key, which value is the command or script to perform the custom
bootstrap procedure. The exit code of the ``command`` dictates if the bootstrap succeeded or failed.
When calling ``command``, Patroni will pass the following arguments to the ``command`` call:
* ``--scope``: contains the value of ``scope`` configuration;
* ``--data_dir``: contains the value of the ``postgresql.data_dir`` configuration.
You can avoid that behavior by filling the optional key ``no_params`` with the value ``False`` in the
configuration file, which will instruct Patroni to not pass these parameters to the ``command`` call.
Besides that, a couple more keys are supported in *config*, but optional:
* ``keep_existing_recovery_conf``: if ``True``, instruct Patroni to not remove the existing
``recovery.conf`` (PostgreSQL <= 11), to not discard recovery parameters from the configuration
(PostgreSQL >= 12), and to not remove the files ``recovery.signal`` or ``standby.signal``
(PostgreSQL >= 12). This is specially useful when you are restoring backups through tools like
pgBackRest and Barman, in which case they generated the appropriate recovery settings for you;
* ``recovery_conf``: a section containing a map, where each key is the name of a recovery related
setting, and the value is the value of the corresponding setting.
Any key/value other than the ones that were described above will be interpreted as additional arguments for
the ``command`` call. They will all be added to the call in the format ``--key=value``.
:returns: ``True`` if the bootstrap was successful, i.e. the execution of the custom ``command`` from *config*
exited with code ``0``, ``False`` otherwise.
"""
self._postgresql.set_state('running custom bootstrap script')
params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope,
'--datadir=' + self._postgresql.data_dir]
# Add custom parameters specified by the user
reserved_args = {'command', 'no_params', 'keep_existing_recovery_conf', 'recovery_conf', 'scope', 'datadir'}
params += [f"--{arg}={val}" for arg, val in config.items() if arg not in reserved_args]
try:
logger.info('Running custom bootstrap script: %s', config['command'])
if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0:
@@ -439,10 +400,7 @@ BEGIN
END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
postgresql.query(sql)
if config.get('users'):
logger.warning('User creation via "bootstrap.users" will be removed in v4.0.0')
for name, value in (config.get('users') or EMPTY_DICT).items():
for name, value in (config.get('users') or {}).items():
if all(name != a.get('username') for a in (superuser, replication, rewind)):
self.create_or_update_role(name, value.get('password'), value.get('options', []))
@@ -465,15 +423,15 @@ END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
postgresql.restart()
else:
postgresql.config.replace_pg_hba()
if postgresql.pending_restart_reason:
if postgresql.pending_restart:
postgresql.restart()
else:
postgresql.reload()
time.sleep(1) # give a time to postgres to "reload" configuration files
postgresql.connection().close() # close connection to reconnect with a new password
else: # initdb
# We may want create database and extension for some MPP clusters
self._postgresql.mpp_handler.bootstrap()
# We may want create database and extension for citus
self._postgresql.citus_handler.bootstrap()
except Exception:
logger.exception('post_bootstrap')
task.complete(False)
+2 -7
View File
@@ -1,9 +1,8 @@
import logging
import sys
from enum import Enum
from threading import Condition, Thread
from typing import Any, Dict, List
from typing import List
from .cancellable import CancellableExecutor, CancellableSubprocess
@@ -31,9 +30,7 @@ class OnReloadExecutor(CancellableSubprocess):
self.cancel(kill=True)
self._kill_children()
with self._lock:
started = self._start_process(cmd, close_fds=True)
if started and self._process is not None:
Thread(target=self._process.wait).start()
self._start_process(cmd, close_fds=True)
class CallbackExecutor(CancellableExecutor, Thread):
@@ -54,8 +51,6 @@ class CallbackExecutor(CancellableExecutor, Thread):
If it couldn't be killed we wait until it finishes.
:param cmd: command to be executed"""
kwargs: Dict[str, Any] = {'stacklevel': 3} if sys.version_info >= (3, 8) else {}
logger.debug('CallbackExecutor.call(%s)', cmd, **kwargs)
if cmd[-3] == CallbackAction.ON_RELOAD:
return self._on_reload_executor.call_nowait(cmd)
+1 -2
View File
@@ -100,8 +100,7 @@ class CancellableSubprocess(CancellableExecutor):
if started and self._process is not None:
if isinstance(communicate, dict):
communicate['stdout'], communicate['stderr'] = \
self._process.communicate(input_data) # pyright: ignore [reportGeneralTypeIssues]
communicate['stdout'], communicate['stderr'] = self._process.communicate(input_data)
return self._process.wait()
finally:
with self._lock:
@@ -6,15 +6,12 @@ from threading import Condition, Event, Thread
from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from . import AbstractMPP, AbstractMPPHandler
from ...dcs import Cluster
from ...psycopg import connect, quote_ident, ProgrammingError
from ...utils import parse_int
from ..dcs import CITUS_COORDINATOR_GROUP_ID, Cluster
from ..psycopg import connect, quote_ident
if TYPE_CHECKING: # pragma: no cover
from .. import Postgresql
from . import Postgresql
CITUS_COORDINATOR_GROUP_ID = 0
CITUS_SLOT_NAME_RE = re.compile(r'^citus_shard_(move|split)_slot(_[1-9][0-9]*){2,3}$')
logger = logging.getLogger(__name__)
@@ -66,45 +63,13 @@ class PgDistNode(object):
return str(self)
class Citus(AbstractMPP):
class CitusHandler(Thread):
group_re = re.compile('^(0|[1-9][0-9]*)$')
@staticmethod
def validate_config(config: Union[Any, Dict[str, Union[str, int]]]) -> bool:
"""Check whether provided config is good for a given MPP.
:param config: configuration of ``citus`` MPP section.
:returns: ``True`` is config passes validation, otherwise ``False``.
"""
return isinstance(config, dict) \
and isinstance(config.get('database'), str) \
and parse_int(config.get('group')) is not None
@property
def group(self) -> int:
"""The group of this Citus node."""
return int(self._config['group'])
@property
def coordinator_group_id(self) -> int:
"""The group id of the Citus coordinator PostgreSQL cluster."""
return CITUS_COORDINATOR_GROUP_ID
class CitusHandler(Citus, AbstractMPPHandler, Thread):
"""Define the interfaces for handling an underlying Citus cluster."""
def __init__(self, postgresql: 'Postgresql', config: Dict[str, Union[str, int]]) -> None:
""""Initialize a new instance of :class:`CitusHandler`.
:param postgresql: the Postgres node.
:param config: the ``citus`` MPP config section.
"""
Thread.__init__(self)
AbstractMPPHandler.__init__(self, postgresql, config)
def __init__(self, postgresql: 'Postgresql', config: Optional[Dict[str, Union[str, int]]]) -> None:
super(CitusHandler, self).__init__()
self.daemon = True
self._postgresql = postgresql
self._config = config
if config:
self._connection = postgresql.connection_pool.get(
'citus', {'dbname': config['database'],
@@ -116,19 +81,26 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
self._condition = Condition() # protects _pg_dist_node, _tasks, _in_flight, and _schedule_load_pg_dist_node
self.schedule_cache_rebuild()
def schedule_cache_rebuild(self) -> None:
"""Cache rebuild handler.
def is_enabled(self) -> bool:
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:
self._schedule_load_pg_dist_node = True
def on_demote(self) -> None:
with self._condition:
self._pg_dist_node.clear()
empty_tasks: List[PgDistNode] = []
self._tasks[:] = empty_tasks
self._tasks[:] = []
self._in_flight = None
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
@@ -161,8 +133,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}
return True
def sync_meta_data(self, cluster: Cluster) -> None:
"""Maintain the ``pg_dist_node`` from the coordinator leader every heartbeat loop.
def sync_pg_dist_node(self, cluster: Cluster) -> None:
"""Maintain the `pg_dist_node` from the coordinator leader every heartbeat loop.
We can't always rely on REST API calls from worker nodes in order
to maintain `pg_dist_node`, therefore at least once per heartbeat
@@ -323,16 +295,16 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
with self._condition:
i = self.find_task_by_group(task.group)
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_meta_data().
# The `PgDistNode.timeout` == None is an indicator that it was scheduled from the sync_pg_dist_node().
if task.timeout is None:
# We don't want to override the already existing task created from REST API.
if i is not None and self._tasks[i].timeout is not None:
return False
# There is a little race condition with tasks created from REST API - the call made "before" the member
# key is updated in DCS. Therefore it is possible that :func:`sync_meta_data` will try to create a task
# based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
# Only when the timeout is reached new tasks could be scheduled from sync_meta_data()
# key is updated in DCS. Therefore it is possible that :func:`sync_pg_dist_node` will try to create a
# task based on the outdated values of "state"/"role". To solve it we introduce an artificial timeout.
# Only when the timeout is reached new tasks could be scheduled from sync_pg_dist_node()
if self._in_flight and self._in_flight.group == task.group and self._in_flight.timeout is not None\
and self._in_flight.deadline > time.time():
return False
@@ -380,10 +352,9 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
task.wait()
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,
'options': '-c synchronous_commit=local -c statement_timeout=0'}
if self._config['database'] != self._postgresql.database:
@@ -392,11 +363,6 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
with conn.cursor() as cur:
cur.execute('CREATE DATABASE {0}'.format(
quote_ident(self._config['database'], conn)).encode('utf-8'))
except ProgrammingError as exc:
if exc.diag.sqlstate == '42P04': # DuplicateDatabase
logger.debug('Exception when creating database: %r', exc)
else:
raise exc
finally:
conn.close()
@@ -404,7 +370,7 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
conn = connect(**conn_kwargs)
try:
with conn.cursor() as cur:
cur.execute('CREATE EXTENSION IF NOT EXISTS citus')
cur.execute('CREATE EXTENSION citus')
superuser = self._postgresql.config.superuser
params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser}
@@ -421,10 +387,9 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
conn.close()
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
shared_preload_libraries = list(filter(
lambda el: el and el != 'citus',
@@ -432,28 +397,15 @@ class CitusHandler(Citus, AbstractMPPHandler, Thread):
parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries)
# if not explicitly set Citus overrides max_prepared_transactions to max_connections*2
if parameters['max_prepared_transactions'] == 0:
if parameters.get('max_prepared_transactions') == 0:
parameters['max_prepared_transactions'] = parameters['max_connections'] * 2
# Resharding in Citus implemented using logical replication
parameters['wal_level'] = 'logical'
# Sometimes Citus needs to connect to the local postgres. We will do it the same way as Patroni does.
parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
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``.
"""
if self._postgresql.is_primary() and slot['type'] == 'logical' and slot['database'] == self._config['database']:
if isinstance(self._config, dict) and self._postgresql.is_primary() and\
slot['type'] == 'logical' and slot['database'] == self._config['database']:
m = CITUS_SLOT_NAME_RE.match(slot['name'])
return bool(m and {'move': 'pgoutput', 'split': 'citus'}.get(m.group(1)) == slot['plugin'])
return False
+42 -109
View File
@@ -9,16 +9,14 @@ import time
from contextlib import contextmanager
from urllib.parse import urlparse, parse_qsl, unquote
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 .. import global_config
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet, EMPTY_DICT
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
from ..exceptions import PatroniFatalException, PostgresConnectionException
from ..file_perm import pg_perm
from ..utils import (compare_values, maybe_convert_from_base_unit, parse_bool, parse_int,
split_host_port, uri, validate_directory, is_subpath)
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, validate_directory, is_subpath
from ..validator import IntValidator, EnumValidator
if TYPE_CHECKING: # pragma: no cover
@@ -246,10 +244,9 @@ class ConfigWriter(object):
self._fd.write(line)
self._fd.write('\n')
def writelines(self, lines: List[Optional[str]]) -> None:
def writelines(self, lines: List[str]) -> None:
for line in lines:
if isinstance(line, str):
self.writeline(line)
self.writeline(line)
@staticmethod
def escape(value: Any) -> str: # Escape (by doubling) any single quotes or backslashes in given string
@@ -271,29 +268,6 @@ def _bool_is_true_validator(value: Any) -> bool:
return parse_bool(value) is True
def get_param_diff(old_value: Any, new_value: Any,
vartype: Optional[str] = None, unit: Optional[str] = None) -> Dict[str, str]:
"""Get a dictionary representing a single PG parameter's value diff.
:param old_value: current :class:`str` parameter value.
:param new_value: :class:`str` value of the paramater after a restart.
:param vartype: the target type to parse old/new_value. See ``vartype`` argument of
:func:`~patroni.utils.maybe_convert_from_base_unit`.
:param unit: unit of *old/new_value*. See ``base_unit`` argument of
:func:`~patroni.utils.maybe_convert_from_base_unit`.
:returns: a :class:`dict` object that contains two keys: ``old_value`` and ``new_value``
with their values casted to :class:`str` and converted from base units (if possible).
"""
str_value: Callable[[Any], str] = lambda x: '' if x is None else str(x)
return {
'old_value': (maybe_convert_from_base_unit(str_value(old_value), vartype, unit)
if vartype else str_value(old_value)),
'new_value': (maybe_convert_from_base_unit(str_value(new_value), vartype, unit)
if vartype else str_value(new_value))
}
class ConfigHandler(object):
# List of parameters which must be always passed to postmaster as command line options
@@ -352,34 +326,14 @@ class ConfigHandler(object):
.format(self._pgpass))
self._passfile = None
self._passfile_mtime = None
self._synchronous_standby_names = None
self._postmaster_ctime = None
self._current_recovery_params: Optional[CaseInsensitiveDict] = None
self._config = {}
self._recovery_params = CaseInsensitiveDict()
self._server_parameters: CaseInsensitiveDict = CaseInsensitiveDict()
self._server_parameters: CaseInsensitiveDict
self.reload_config(config)
def load_current_server_parameters(self) -> None:
"""Read GUC's values from ``pg_settings`` when Patroni is joining the the postgres that is already running."""
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator]
keep_values = {k: self._server_parameters[k] for k in exclude}
server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
"SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings"
" WHERE (source IN ('command line', 'environment variable') OR sourcefile = %s)"
" AND pg_catalog.lower(name) != ALL(%s)", self._postgresql_conf, exclude)})
recovery_params = CaseInsensitiveDict({k: server_parameters.pop(k) for k in self._RECOVERY_PARAMETERS
if k in server_parameters})
# We also want to load current settings of recovery parameters, including primary_conninfo
# and primary_slot_name, otherwise patronictl restart will update postgresql.conf
# and remove them, what in the worst case will cause another restart.
# We are doing it only for PostgresSQL v12 onwards, because older version still have recovery.conf
if not self._postgresql.is_primary() and self._postgresql.major_version >= 120000:
# primary_conninfo is expected to be a dict, therefore we need to parse it
recovery_params['primary_conninfo'] = parse_dsn(recovery_params.pop('primary_conninfo', '')) or {}
self._recovery_params = recovery_params
self._server_parameters = CaseInsensitiveDict({**server_parameters, **keep_values})
def setup_server_parameters(self) -> None:
self._server_parameters = self.get_server_parameters(self._config)
self._adjust_recovery_parameters()
@@ -619,8 +573,7 @@ class ConfigHandler(object):
fd.write_param(name, value)
def build_recovery_params(self, member: Union[Leader, Member, None]) -> CaseInsensitiveDict:
default: Dict[str, Any] = {}
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or default).items()
recovery_params = CaseInsensitiveDict({p: v for p, v in (self.get('recovery_conf') or {}).items()
if not p.lower().startswith('recovery_target')
and p.lower() not in ('primary_conninfo', 'primary_slot_name')})
recovery_params.update({'standby_mode': 'on', 'recovery_target_timeline': 'latest'})
@@ -633,14 +586,15 @@ class ConfigHandler(object):
is_remote_member = isinstance(member, RemoteMember)
primary_conninfo = self.primary_conninfo_params(member)
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):
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)
# 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
# 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'
recovery_params['primary_conninfo'] = primary_conninfo
@@ -846,7 +800,7 @@ class ConfigHandler(object):
required['restart' if mtype else 'reload'] += 1
wanted_recovery_params = self.build_recovery_params(member)
for param, value in (self._current_recovery_params or EMPTY_DICT).items():
for param, value in (self._current_recovery_params or {}).items():
# Skip certain parameters defined in the included postgres config files
# if we know that they are not specified in the patroni configuration.
if len(value) > 2 and value[2] not in (self._postgresql_conf, self._auto_conf) and \
@@ -967,16 +921,15 @@ class ConfigHandler(object):
parameters = config['parameters'].copy()
listen_addresses, port = split_host_port(config['listen'], 5432)
parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port))
if global_config.is_synchronous_mode:
synchronous_standby_names = self._server_parameters.get('synchronous_standby_names')
if synchronous_standby_names is None:
if global_config.is_synchronous_mode_strict\
if not self._postgresql.global_config or self._postgresql.global_config.is_synchronous_mode:
if self._synchronous_standby_names is None:
if self._postgresql.global_config and self._postgresql.global_config.is_synchronous_mode_strict\
and self._postgresql.role in ('master', 'primary', 'promoted'):
parameters['synchronous_standby_names'] = '*'
else:
parameters.pop('synchronous_standby_names', None)
else:
parameters['synchronous_standby_names'] = synchronous_standby_names
parameters['synchronous_standby_names'] = self._synchronous_standby_names
# Handle hot_standby <-> replica rename
if parameters.get('wal_level') == ('hot_standby' if self._postgresql.major_version >= 90600 else 'replica'):
@@ -992,7 +945,7 @@ class ConfigHandler(object):
wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB')
parameters.setdefault('wal_keep_segments', int(((wal_keep_size or 0) + 8) / 16))
self._postgresql.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
or self._postgresql.major_version >= self.CMDLINE_OPTIONS.get(k, (0, 1, 90100))[2]})
@@ -1073,14 +1026,17 @@ class ConfigHandler(object):
# "notify" connection_pool about the "new" local connection address
self._postgresql.connection_pool.conn_kwargs = local_conn_kwargs
def _get_pg_settings(self, names: Collection[str]) -> Dict[Any, Tuple[Any, ...]]:
def _get_pg_settings(
self, names: Collection[str]
) -> Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]]:
return {r[0]: r for r in self._postgresql.query(('SELECT name, setting, unit, vartype, context, sourcefile'
+ ' FROM pg_catalog.pg_settings '
+ ' WHERE pg_catalog.lower(name) = ANY(%s)'),
[n.lower() for n in names])}
@staticmethod
def _handle_wal_buffers(old_values: Dict[Any, Tuple[Any, ...]], changes: CaseInsensitiveDict) -> None:
def _handle_wal_buffers(old_values: Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]],
changes: CaseInsensitiveDict) -> None:
wal_block_size = parse_int(old_values['wal_block_size'][1]) or 8192
wal_segment_size = old_values['wal_segment_size']
wal_segment_unit = parse_int(wal_segment_size[2], 'B') or 8192 \
@@ -1101,15 +1057,13 @@ class ConfigHandler(object):
def reload_config(self, config: Dict[str, Any], sighup: bool = False) -> None:
self._superuser = config['authentication'].get('superuser', {})
server_parameters = self.get_server_parameters(config)
params_skip_changes = CaseInsensitiveSet((*self._RECOVERY_PARAMETERS, 'hot_standby', 'wal_log_hints'))
conf_changed = hba_changed = ident_changed = local_connection_address_changed = False
param_diff = CaseInsensitiveDict()
conf_changed = hba_changed = ident_changed = local_connection_address_changed = pending_restart = False
if self._postgresql.state == 'running':
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items()
if p not in params_skip_changes})
if p.lower() not in self._RECOVERY_PARAMETERS})
changes.update({p: None for p in self._server_parameters.keys()
if not (p in changes or p in params_skip_changes)})
if not (p in changes or p.lower() in self._RECOVERY_PARAMETERS)})
if changes:
undef = []
if 'wal_buffers' in changes: # we need to calculate the default value of wal_buffers
@@ -1128,28 +1082,20 @@ class ConfigHandler(object):
if new_value is None or not compare_values(r[3], r[2], r[1], new_value):
conf_changed = True
if r[4] == 'postmaster':
param_diff[r[0]] = get_param_diff(r[1], new_value, r[3], r[2])
logger.info("Changed %s from '%s' to '%s' (restart might be required)",
r[0], param_diff[r[0]]['old_value'], new_value)
pending_restart = True
logger.info('Changed %s from %s to %s (restart might be required)',
r[0], r[1], new_value)
if config.get('use_unix_socket') and r[0] == 'unix_socket_directories'\
or r[0] in ('listen_addresses', 'port'):
local_connection_address_changed = True
else:
logger.info("Changed %s from '%s' to '%s'",
r[0], maybe_convert_from_base_unit(r[1], r[3], r[2]), new_value)
elif r[0] in self._server_parameters \
and not compare_values(r[3], r[2], r[1], self._server_parameters[r[0]]):
# Check if any parameter was set back to the current pg_settings value
# We can use pg_settings value here, as it is proved to be equal to new_value
logger.info("Changed %s from '%s' to '%s'", r[0], self._server_parameters[r[0]], new_value)
conf_changed = True
logger.info('Changed %s from %s to %s', r[0], r[1], new_value)
for param, value in changes.items():
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 \
or str(value) != str(self._server_parameters[param]):
logger.info("Changed %s from '%s' to '%s'",
param, self._server_parameters.get(param), value)
logger.info('Changed %s from %s to %s', param, self._server_parameters.get(param), value)
conf_changed = True
elif param in server_parameters:
logger.warning('Removing invalid parameter `%s` from postgresql.parameters', param)
@@ -1164,6 +1110,7 @@ class ConfigHandler(object):
ident_changed = self._config.get('pg_ident', []) != config['pg_ident']
self._config = config
self._postgresql.set_pending_restart(pending_restart)
self._server_parameters = server_parameters
self._adjust_recovery_parameters()
self._krbsrvname = config.get('krbsrvname')
@@ -1193,36 +1140,25 @@ class ConfigHandler(object):
if self._postgresql.major_version >= 90500:
time.sleep(1)
try:
settings_diff: CaseInsensitiveDict = CaseInsensitiveDict()
for param, value, unit, vartype in self._postgresql.query(
'SELECT name, pg_catalog.current_setting(name), unit, vartype FROM pg_catalog.pg_settings'
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
[n.lower() for n in params_skip_changes]):
new_value = self._postgresql.get_guc_value(param)
new_value = '?' if new_value is None else new_value
settings_diff[param] = get_param_diff(value, new_value, vartype, unit)
external_change = {param: value for param, value in settings_diff.items()
if param not in param_diff or value != param_diff[param]}
if external_change:
logger.info("PostgreSQL configuration parameters requiring restart"
" (%s) seem to be changed bypassing Patroni config."
" Setting 'Pending restart' flag", ', '.join(external_change))
param_diff = settings_diff
pending_restart = self._postgresql.query(
'SELECT COUNT(*) FROM pg_catalog.pg_settings'
' WHERE pg_catalog.lower(name) != ALL(%s) AND pending_restart',
[n.lower() for n in self._RECOVERY_PARAMETERS])[0][0] > 0
self._postgresql.set_pending_restart(pending_restart)
except Exception as e:
logger.warning('Exception %r when running query', e)
else:
logger.info('No PostgreSQL configuration items changed, nothing to reload.')
self._postgresql.set_pending_restart_reason(param_diff)
def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]:
"""Updates synchronous_standby_names and reloads if necessary.
:returns: True if value was updated."""
if value != self._server_parameters.get('synchronous_standby_names'):
if value != self._synchronous_standby_names:
if value is None:
self._server_parameters.pop('synchronous_standby_names', None)
else:
self._server_parameters['synchronous_standby_names'] = value
self._synchronous_standby_names = value
if self._postgresql.state == 'running':
self.write_postgresql_conf()
self._postgresql.reload()
@@ -1257,7 +1193,6 @@ class ConfigHandler(object):
data = self._postgresql.controldata()
effective_configuration = self._server_parameters.copy()
param_diff = CaseInsensitiveDict()
for name, cname in options_mapping.items():
value = parse_int(effective_configuration[name])
if cname not in data:
@@ -1267,10 +1202,7 @@ class ConfigHandler(object):
cvalue = parse_int(data[cname])
if cvalue is not None and value is not None and cvalue > value:
effective_configuration[name] = cvalue
logger.info("%s value in pg_controldata: %d, in the global configuration: %d."
" pg_controldata value will be used. Setting 'Pending restart' flag", name, cvalue, value)
param_diff[name] = get_param_diff(cvalue, value)
self._postgresql.set_pending_restart_reason(param_diff)
self._postgresql.set_pending_restart(True)
# 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'.
@@ -1287,6 +1219,7 @@ class ConfigHandler(object):
if disable_hot_standby:
effective_configuration['hot_standby'] = 'off'
self._postgresql.set_pending_restart(True)
return effective_configuration
@@ -1325,4 +1258,4 @@ class ConfigHandler(object):
return self._config.get(key, default)
def restore_command(self) -> Optional[str]:
return (self.get('recovery_conf') or EMPTY_DICT).get('restore_command')
return (self.get('recovery_conf') or {}).get('restore_command')
+1 -2
View File
@@ -147,8 +147,7 @@ class ConnectionPool:
def close(self) -> None:
"""Close all named connections from Patroni to PostgreSQL registered in the pool."""
with self._lock:
closed_connections = [conn.close(True) for conn in self._connections.values()]
if any(closed_connections):
if any(conn.close(True) for conn in self._connections.values()):
logger.info("closed patroni connections to postgres")
-317
View File
@@ -1,317 +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.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(__package__, str)
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()
+1 -1
View File
@@ -176,7 +176,7 @@ class PostmasterProcess(psutil.Process):
return not self.is_running()
def wait_for_user_backends_to_close(self, stop_timeout: Optional[float]) -> None:
# These regexps are cross checked against versions PostgreSQL 9.1 .. 16
# These regexps are cross checked against versions PostgreSQL 9.1 .. 15
aux_proc_re = re.compile("(?:postgres:)( .*:)? (?:(?:archiver|startup|autovacuum launcher|autovacuum worker|"
"checkpointer|logger|stats collector|wal receiver|wal writer|writer)(?: process )?|"
"walreceiver|wal sender process|walsender|walwriter|background writer|"
+16 -37
View File
@@ -13,7 +13,6 @@ from . import Postgresql
from .connection import get_connection_cursor
from .misc import format_lsn, fsync_dir, parse_history, parse_lsn
from ..async_executor import CriticalTask
from ..collections import EMPTY_DICT
from ..dcs import Leader, RemoteMember
logger = logging.getLogger(__name__)
@@ -102,26 +101,12 @@ class Rewind(object):
return 'not accessible or not healty'
def _get_checkpoint_end(self, timeline: int, lsn: int) -> int:
"""Get the end of checkpoint record from WAL.
"""The checkpoint record size in WAL depends on postgres major version and platform (memory alignment).
Hence, the only reliable way to figure out where it ends, read the record from file with the help of pg_waldump
and parse the output. We are trying to read two records, and expect that it will fail to read the second one:
`pg_waldump: fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0`
The error message contains information about LSN of the next record, which is exactly where checkpoint ends."""
.. note::
The checkpoint record size in WAL depends on postgres major version and platform (memory alignment).
Hence, the only reliable way to figure out where it ends, is to read the record from file with the
help of ``pg_waldump`` and parse the output.
We are trying to read two records, and expect that it will fail to read the second record with message:
fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0; or
fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: expected at least 24, got 0
The error message contains information about LSN of the next record, which is exactly where checkpoint ends.
:param timeline: the checkpoint *timeline* from ``pg_controldata``.
:param lsn: the checkpoint *location* as :class:`int` from ``pg_controldata``.
:returns: the end of checkpoint record as :class:`int` or ``0`` if failed to parse ``pg_waldump`` output.
"""
lsn8 = format_lsn(lsn, True)
lsn_str = format_lsn(lsn)
out, err = self._postgresql.waldump(timeline, lsn_str, 2)
@@ -132,17 +117,12 @@ class Rewind(object):
if len(out) == 1 and len(err) == 1 and ', lsn: {0}, prev '.format(lsn8) in out[0] and pattern in err[0]:
i = err[0].find(pattern) + len(pattern)
# Message format depends on the major version:
# * expected at least -- starting from v16
# * wanted -- before v16
# We will simply check all possible combinations.
for pattern in (': expected at least ', ': wanted '):
j = err[0].find(pattern, i)
if j > -1:
try:
return parse_lsn(err[0][i:j])
except Exception as e:
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
j = err[0].find(": wanted ", i)
if j > -1:
try:
return parse_lsn(err[0][i:j])
except Exception as e:
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
logger.error('Failed to parse pg_%sdump output', self._postgresql.wal_name)
logger.error(' stdout=%s', '\n'.join(out))
logger.error(' stderr=%s', '\n'.join(err))
@@ -178,7 +158,7 @@ class Rewind(object):
def _get_local_timeline_lsn(self) -> Tuple[Optional[bool], Optional[int], Optional[int]]:
if self._postgresql.is_running(): # if postgres is running - get timeline from replication connection
in_recovery = True
timeline = self._postgresql.get_replica_timeline()
timeline = self._postgresql.received_timeline() or self._postgresql.get_replica_timeline()
lsn = self._postgresql.replayed_location()
else: # otherwise analyze pg_controldata output
in_recovery, timeline, lsn = self._get_local_timeline_lsn_from_controldata()
@@ -210,10 +190,9 @@ class Rewind(object):
ret = member.conn_kwargs(auth)
if not ret.get('dbname'):
ret['dbname'] = self._postgresql.database
# Add target_session_attrs to make sure we hit the primary.
# It is not strictly necessary for starting from PostgreSQL v14, which made it possible
# to rewind from standby, but doing it from the real primary is always safer.
if self._postgresql.major_version >= 100000:
# Add target_session_attrs in case more than one hostname is specified
# (libpq client-side failover) making sure we hit the primary
if 'target_session_attrs' not in ret and self._postgresql.major_version >= 100000:
ret['target_session_attrs'] = 'read-write'
return ret
@@ -419,7 +398,7 @@ class Rewind(object):
dsn = self._postgresql.config.format_dsn(r, True)
logger.info('running pg_rewind from %s', dsn)
restore_command = (self._postgresql.config.get('recovery_conf') or EMPTY_DICT).get('restore_command') \
restore_command = (self._postgresql.config.get('recovery_conf') or {}).get('restore_command') \
if self._postgresql.major_version < 120000 else self._postgresql.get_guc_value('restore_command')
# Until v15 pg_rewind expected postgresql.conf to be inside $PGDATA, which is not the case on e.g. Debian
+47 -57
View File
@@ -13,11 +13,9 @@ from typing import Any, Dict, Iterator, List, Optional, Union, Tuple, TYPE_CHECK
from .connection import get_connection_cursor
from .misc import format_lsn, fsync_dir
from .. import global_config
from ..dcs import Cluster, Leader
from ..file_perm import pg_perm
from ..psycopg import OperationalError
from ..tags import Tags
if TYPE_CHECKING: # pragma: no cover
from psycopg import Cursor
@@ -233,16 +231,15 @@ class SlotsHandler:
ret: Dict[str, int] = {}
slots_dict: Dict[str, Dict[str, Any]] = {slot['slot_name']: slot for slot in slots or []}
for name, value in slots_dict.items():
if name in self._replication_slots:
if compare_slots(value, self._replication_slots[name], 'datoid'):
if value['type'] == 'logical':
ret[name] = value['confirmed_flush_lsn']
self._copy_items(value, self._replication_slots[name])
if slots_dict:
for name, value in slots_dict.items():
if name in self._replication_slots:
if compare_slots(value, self._replication_slots[name], 'datoid'):
if value['type'] == 'logical':
ret[name] = value['confirmed_flush_lsn']
self._copy_items(value, self._replication_slots[name])
else:
self._replication_slots[name]['restart_lsn'] = ret[name] = value['restart_lsn']
else:
self._schedule_load_slots = True
self._schedule_load_slots = True
# It could happen that the slot was deleted in the background, we want to detect this case
if any(name not in slots_dict for name in self._replication_slots.keys()):
@@ -263,19 +260,16 @@ class SlotsHandler:
"""
if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
replication_slots: Dict[str, Dict[str, Any]] = {}
pg_wal_lsn_diff = f"pg_catalog.pg_{self._postgresql.wal_name}_{self._postgresql.lsn_name}_diff"
extra = f", catalog_xmin, {pg_wal_lsn_diff}(confirmed_flush_lsn, '0/0')::bigint" \
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint" \
if self._postgresql.major_version >= 100000 else ""
skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else ''
for r in self._query(f"SELECT slot_name, slot_type, {pg_wal_lsn_diff}(restart_lsn, '0/0')::bigint, plugin,"
f" database, datoid{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}"):
for r in self._query('SELECT slot_name, slot_type, plugin, database, datoid'
f'{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}'):
value = {'type': r[1]}
if r[1] == 'logical':
value.update(plugin=r[3], database=r[4], datoid=r[5])
value.update(plugin=r[2], database=r[3], datoid=r[4])
if self._postgresql.major_version >= 100000:
value.update(catalog_xmin=r[6], confirmed_flush_lsn=r[7])
else:
value['restart_lsn'] = r[2]
value.update(catalog_xmin=r[5], confirmed_flush_lsn=r[6])
replication_slots[r[0]] = value
self._replication_slots = replication_slots
self._schedule_load_slots = False
@@ -291,18 +285,18 @@ class SlotsHandler:
:param name: name of the slot to ignore
:returns: ``True`` if slot *name* matches any slot specified in ``ignore_slots`` configuration,
otherwise will pass through and return result of :meth:`AbstractMPPHandler.ignore_replication_slot`.
otherwise will pass through and return result of :meth:`CitusHandler.ignore_replication_slot`.
"""
slot = self._replication_slots[name]
if cluster.config:
for matcher in global_config.ignore_slots_matchers:
for matcher in cluster.config.ignore_slots_matchers:
if (
(matcher.get("name") is None or matcher["name"] == name)
and all(not matcher.get(a) or matcher[a] == slot.get(a)
for a in ('database', 'plugin', 'type'))
):
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]:
"""Drop a named slot from Postgres.
@@ -319,9 +313,9 @@ class SlotsHandler:
' true AS dropped FROM slots WHERE not active) '
'SELECT active, COALESCE(dropped, false) FROM slots'
' FULL OUTER JOIN dropped ON true'), name)
return (rows[0][0], rows[0][1]) if rows else (False, False)
return rows[0] 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.
.. note::
@@ -332,10 +326,11 @@ class SlotsHandler:
: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 paused: ``True`` if the patroni cluster is currently in a paused state.
"""
# drop old replication slots which are not presented in desired 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)
if dropped:
logger.info("Dropped unknown replication slot '%s'", name)
@@ -358,7 +353,7 @@ class SlotsHandler:
self._schedule_load_slots = True
def _ensure_physical_slots(self, slots: Dict[str, Any]) -> None:
"""Create or advance physical replication *slots*.
"""Create any missing physical replication *slots*.
Any failures are logged and do not interrupt creation of all *slots*.
@@ -367,9 +362,7 @@ class SlotsHandler:
"""
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
for name, value in slots.items():
if value['type'] != 'physical':
continue
if name not in self._replication_slots:
if name not in self._replication_slots and value['type'] == 'physical':
try:
self._query(f"SELECT pg_catalog.pg_create_physical_replication_slot(%s{immediately_reserve})"
f" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots"
@@ -378,15 +371,6 @@ class SlotsHandler:
except Exception:
logger.exception("Failed to create physical replication slot '%s'", name)
self._schedule_load_slots = True
elif self._postgresql.can_advance_slots and self._replication_slots[name]['type'] == 'physical':
value['restart_lsn'] = self._replication_slots[name]['restart_lsn']
lsn = value.get('lsn')
if lsn and lsn > value['restart_lsn']: # The slot has feedback in DCS and needs to be advanced
try:
lsn = format_lsn(lsn)
self._query("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", name, lsn)
except Exception as exc:
logger.error("Error while advancing replication slot %s to position '%s': %r", name, lsn, exc)
@contextmanager
def get_local_connection_cursor(self, **kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
@@ -476,9 +460,12 @@ class SlotsHandler:
# If the logical already exists, copy some information about it into the original structure
if name in self._replication_slots and compare_slots(value, self._replication_slots[name]):
self._copy_items(self._replication_slots[name], value)
if 'lsn' in value and value['confirmed_flush_lsn'] < value['lsn']: # The slot has feedback in DCS
# Skip slots that don't need to be advanced
advance_slots[value['database']][name] = value['lsn']
if 'lsn' in value: # The slot has feedback in DCS
try: # Skip slots that don't need to be advanced
if value['confirmed_flush_lsn'] < int(value['lsn']):
advance_slots[value['database']][name] = int(value['lsn'])
except Exception as e:
logger.error('Failed to parse "%s": %r', value['lsn'], e)
elif name not in self._replication_slots and 'lsn' in value:
# We want to copy only slots with feedback in a DCS
create_slots.append(name)
@@ -493,28 +480,32 @@ class SlotsHandler:
self._schedule_load_slots = True
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.
Read physical and logical slots from ``pg_replication_slots``, then compare to those configured in the DCS.
Read physical and logical slots found on the primary, then compare to those configured in the DCS.
Drop any slots that do not match those required by configuration and are not configured as permanent.
Create any missing physical slots, or advance their position according to feedback stored in DCS.
If we are the primary then create logical slots, otherwise if logical slots are known and active create
them on replica nodes by copying slot files from the primary.
Create any missing physical slots. If we are the leader then logical slots too, otherwise if logical slots
are known and active create them on replica nodes.
: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.
"""
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:
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)
@@ -522,7 +513,7 @@ class SlotsHandler:
self._logical_slots_processing_queue.clear()
self._ensure_logical_slots_primary(slots)
else:
self.check_logical_slots_readiness(cluster, tags)
self.check_logical_slots_readiness(cluster, replicatefrom)
ret = self._ensure_logical_slots_replica(slots)
self._replication_slots = slots
@@ -548,7 +539,7 @@ class SlotsHandler:
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as 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.
1) Retrieve the current ``catalog_xmin`` value for the physical slot from the cluster leader, and
@@ -557,13 +548,13 @@ class SlotsHandler:
3) store logical slot ``catalog_xmin`` when the physical slot ``catalog_xmin`` becomes valid.
: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.
"""
catalog_xmin = None
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:
with self._get_leader_connection_cursor(cluster.leader) as cur:
cur.execute("SELECT slot_name, catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
@@ -641,17 +632,16 @@ class SlotsHandler:
if standby_logical_slot:
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.
: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.
"""
leader = cluster.leader
if not leader:
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]] = {}
with self._get_leader_connection_cursor(leader) as cur:
try:
+5 -3
View File
@@ -5,7 +5,6 @@ import time
from copy import deepcopy
from typing import Collection, List, NamedTuple, Tuple, TYPE_CHECKING
from .. import global_config
from ..collections import CaseInsensitiveDict, CaseInsensitiveSet
from ..dcs import Cluster
from ..psycopg import quote_ident as _quote_ident
@@ -304,8 +303,11 @@ END;$$""")
replica_list = _ReplicaList(self._postgresql, cluster)
self._process_replica_readiness(cluster, replica_list)
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
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()
+19 -5
View File
@@ -1,11 +1,11 @@
import abc
from copy import deepcopy
import logging
import os
import yaml
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 ..exceptions import PatroniException
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."""
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.
: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
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*.
"""
try:
with file.open(encoding='UTF-8') as stream:
with open(file, encoding='UTF-8') as stream:
return yaml.safe_load(stream)
except Exception as exc:
raise InvalidGucValidatorsFile(
@@ -385,7 +385,21 @@ def _load_postgres_gucs_validators() -> None:
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:
config: Dict[str, Any] = _read_postgres_gucs_validators_file(file)
except InvalidGucValidatorsFile as exc:
+2 -3
View File
@@ -42,8 +42,7 @@ try:
value.prepare(conn)
return value.getquoted().decode('utf-8')
except ImportError:
from psycopg import connect as __connect # pyright: ignore [reportUnknownVariableType]
from psycopg import sql, Error, DatabaseError, OperationalError, ProgrammingError
from psycopg import connect as __connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
def _connect(dsn: Optional[str] = None, **kwargs: Any) -> 'Connection[Any]':
"""Call :func:`psycopg.connect` with *dsn* and ``**kwargs``.
@@ -57,7 +56,7 @@ except ImportError:
:returns: a connection to the database.
"""
ret: 'Connection[Any]' = __connect(dsn or "", **kwargs)
ret = __connect(dsn or "", **kwargs)
setattr(ret, 'server_version', ret.pgconn.server_version) # compatibility with psycopg2
return ret
-1
View File
@@ -1 +0,0 @@
"""Create :mod:`patroni.scripts.barman`."""
-240
View File
@@ -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()
-146
View File
@@ -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)
-122
View File
@@ -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)
-308
View File
@@ -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"]
+6 -41
View File
@@ -3,16 +3,11 @@ import abc
from typing import Any, Dict, Optional
from patroni.utils import parse_int, parse_bool
class Tags(abc.ABC):
"""An abstract class that encapsulates all the ``tags`` logic.
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
@@ -23,20 +18,16 @@ class Tags(abc.ABC):
.. note::
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
they all are boolean values that default to disabled.
However ``nofailover`` tag is always returned if ``failover_priority`` tag is defined. In this case, we need
both values to see if they are contradictory and the ``nofailover`` value should be used.
For the Patroni predefined tags, the returning object will only contain them if they are enabled as they
all are boolean values that default to disabled.
:returns: a dictionary of tags set for this node. The key is the tag name, and the value is the corresponding
tag value.
"""
return {tag: value for tag, value in tags.items()
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync', 'nostream'),
value,
tag == 'nofailover' and 'failover_priority' in tags))}
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
@property
@abc.abstractmethod
@@ -54,29 +45,8 @@ class Tags(abc.ABC):
@property
def nofailover(self) -> bool:
"""Common logic for obtaining the value of ``nofailover`` from ``tags`` if defined.
If ``nofailover`` is not defined, this methods returns ``True`` if ``failover_priority`` is non-positive,
``False`` otherwise.
"""
from_tags = self.tags.get('nofailover')
if from_tags is not None:
# Value of `nofailover` takes precedence over `failover_priority`
return bool(from_tags)
failover_priority = parse_int(self.tags.get('failover_priority'))
return failover_priority is not None and failover_priority <= 0
@property
def failover_priority(self) -> int:
"""Common logic for obtaining the value of ``failover_priority`` from ``tags`` if defined.
If ``nofailover`` is defined as ``True``, this will return ``0``. Otherwise, it will return the value of
``failover_priority``, defaulting to ``1`` if it's not defined or invalid.
"""
from_tags = self.tags.get('nofailover')
failover_priority = parse_int(self.tags.get('failover_priority'))
failover_priority = 1 if failover_priority is None else failover_priority
return 0 if from_tags else failover_priority
"""``True`` if ``nofailover`` is ``True``, else ``False``."""
return bool(self.tags.get('nofailover', False))
@property
def noloadbalance(self) -> bool:
@@ -92,8 +62,3 @@ class Tags(abc.ABC):
def replicatefrom(self) -> Optional[str]:
"""Value of ``replicatefrom`` tag, if any."""
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
+76 -201
View File
@@ -9,8 +9,9 @@
:var DBL_RE: regular expression to match double precision numbers, signed or unsigned. Matches scientific notation too.
:var WHITESPACE_RE: regular expression to match whitespace characters
"""
import datetime
import dateutil.parser
import errno
import itertools
import logging
import os
import platform
@@ -21,11 +22,11 @@ import subprocess
import sys
import tempfile
import time
from enum import Enum
from shlex import split
from typing import Any, Callable, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
from collections import OrderedDict
from dateutil import tz
from json import JSONDecoder
from urllib3.response import HTTPResponse
@@ -35,6 +36,7 @@ from .version import __version__
if TYPE_CHECKING: # pragma: no cover
from .dcs import Cluster
from .config import GlobalConfig
tzutc = tz.tzutc()
@@ -48,37 +50,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)
def get_conversion_table(base_unit: str) -> Dict[str, Dict[str, Union[int, float]]]:
"""Get conversion table for the specified base unit.
If no conversion table exists for the passed unit, return an empty :class:`OrderedDict`.
:param base_unit: unit to choose the conversion table for.
:returns: :class:`OrderedDict` object.
"""
memory_unit_conversion_table: Dict[str, Dict[str, Union[int, float]]] = OrderedDict([
('TB', {'B': 1024**4, 'kB': 1024**3, 'MB': 1024**2}),
('GB', {'B': 1024**3, 'kB': 1024**2, 'MB': 1024}),
('MB', {'B': 1024**2, 'kB': 1024, 'MB': 1}),
('kB', {'B': 1024, 'kB': 1, 'MB': 1024**-1}),
('B', {'B': 1, 'kB': 1024**-1, 'MB': 1024**-2})
])
time_unit_conversion_table: Dict[str, Dict[str, Union[int, float]]] = OrderedDict([
('d', {'ms': 1000 * 60**2 * 24, 's': 60**2 * 24, 'min': 60 * 24}),
('h', {'ms': 1000 * 60**2, 's': 60**2, 'min': 60}),
('min', {'ms': 1000 * 60, 's': 60, 'min': 1}),
('s', {'ms': 1000, 's': 1, 'min': 60**-1}),
('ms', {'ms': 1, 's': 1000**-1, 'min': 1 / (1000 * 60)}),
('us', {'ms': 1000**-1, 's': 1000**-2, 'min': 1 / (1000**2 * 60)})
])
if base_unit in ('B', 'kB', 'MB'):
return memory_unit_conversion_table
elif base_unit in ('ms', 's', 'min'):
return time_unit_conversion_table
return OrderedDict()
def deep_compare(obj1: Dict[Any, Union[Any, Dict[Any, Any]]], obj2: Dict[Any, Union[Any, Dict[Any, Any]]]) -> bool:
"""Recursively compare two dictionaries to check if they are equal in terms of keys and values.
@@ -305,152 +276,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
True
"""
base_value, base_unit = strtol(base_unit, False)
if TYPE_CHECKING: # pragma: no cover
assert isinstance(base_value, int)
convert_tbl = get_conversion_table(base_unit)
# {'TB': 'GB', 'GB': 'MB', ...}
round_order = dict(zip(convert_tbl, itertools.islice(convert_tbl, 1, None)))
if unit in convert_tbl and base_unit in convert_tbl[unit]:
value *= convert_tbl[unit][base_unit] / float(base_value)
if unit in round_order:
multiplier = convert_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)
convert: Dict[str, Dict[str, Union[int, float]]] = {
'B': {'B': 1, 'kB': 1024, 'MB': 1024 * 1024, 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024},
'kB': {'B': 1.0 / 1024, 'kB': 1, 'MB': 1024, 'GB': 1024 * 1024, 'TB': 1024 * 1024 * 1024},
'MB': {'B': 1.0 / (1024 * 1024), 'kB': 1.0 / 1024, 'MB': 1, 'GB': 1024, 'TB': 1024 * 1024},
'ms': {'us': 1.0 / 1000, 'ms': 1, 's': 1000, 'min': 1000 * 60, 'h': 1000 * 60 * 60, 'd': 1000 * 60 * 60 * 24},
's': {'us': 1.0 / (1000 * 1000), 'ms': 1.0 / 1000, 's': 1, 'min': 60, 'h': 60 * 60, 'd': 60 * 60 * 24},
'min': {'us': 1.0 / (1000 * 1000 * 60), 'ms': 1.0 / (1000 * 60), 's': 1.0 / 60, 'min': 1, 'h': 60, 'd': 60 * 24}
}
parser, converter = converters.get(vartype, converters['default'])
parsed_value = parser(base_value, None)
if parsed_value:
return converter(parsed_value, base_unit) or base_value
return base_value
round_order = {
'TB': 'GB', 'GB': 'MB', 'MB': 'kB', 'kB': 'B',
'd': 'h', 'h': 'min', 'min': 's', 's': 'ms', 'ms': 'us'
}
if base_unit and base_unit not in convert:
base_value, base_unit = strtol(base_unit, False)
else:
base_value = 1
if base_value is not None and base_unit in convert and unit in convert[base_unit]:
value *= convert[base_unit][unit] / float(base_value)
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]:
@@ -552,23 +404,22 @@ def parse_real(value: Any, base_unit: Optional[str] = None) -> Optional[float]:
return convert_to_base_unit(val, unit, base_unit)
def compare_values(vartype: str, unit: Optional[str], settings_value: Any, config_value: Any) -> bool:
"""Check if the value from ``pg_settings`` and from Patroni config are equivalent after parsing them as *vartype*.
def compare_values(vartype: str, unit: Optional[str], old_value: Any, new_value: Any) -> bool:
"""Check if *old_value* and *new_value* are equivalent after parsing them as *vartype*.
:param vartype: the target type to parse *settings_value* and *config_value* before comparing them.
Accepts any among of the following (case sensitive):
:param vartpe: the target type to parse *old_value* and *new_value* before comparing them. Accepts any among of the
following (case sensitive):
* ``bool``: parse values using :func:`parse_bool`; or
* ``integer``: parse values using :func:`parse_int`; or
* ``real``: parse values using :func:`parse_real`; or
* ``enum``: parse values as lowercase strings; or
* ``string``: parse values as strings. This one is used by default if no valid value is passed as *vartype*.
:param unit: base unit to be used as argument when calling :func:`parse_int` or :func:`parse_real`
for *config_value*.
:param settings_value: value to be compared with *config_value*.
:param config_value: value to be compared with *settings_value*.
:param unit: base unit to be used as argument when calling :func:`parse_int` or :func:`parse_real` for *new_value*.
:param old_value: value to be compared with *new_value*.
:param new_value: value to be compared with *old_value*.
:returns: ``True`` if *settings_value* is equivalent to *config_value* when both are parsed as *vartype*.
:returns: ``True`` if *old_value* is equivalent to *new_value* when both are parsed as *vartype*.
:Example:
@@ -608,8 +459,8 @@ def compare_values(vartype: str, unit: Optional[str], settings_value: Any, confi
}
converter = converters.get(vartype) or converters['string']
old_converted = converter(settings_value, None)
new_converted = converter(config_value, unit)
old_converted = converter(old_value, None)
new_converted = converter(new_value, unit)
return old_converted is not None and new_converted is not None and old_converted == new_converted
@@ -716,7 +567,7 @@ class Retry(object):
return self._cur_stoptime or 0
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 raise_ex: the exception object that will be raised if the *deadline* is smaller than provided *timeout*.
@@ -727,7 +578,8 @@ class Retry(object):
:raises:
: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:
raise raise_ex
return False
@@ -910,10 +762,12 @@ def iter_response_objects(response: HTTPResponse) -> Iterator[Dict[str, Any]]:
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*.
: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*.
@@ -942,16 +796,16 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
* ``from``: name of the member to be demoted;
* ``to``: name of the member to be promoted.
"""
from . import global_config
config = global_config.from_cluster(cluster)
if not global_config:
from patroni.config import get_global_config
global_config = get_global_config(cluster)
leader_name = cluster.leader.name if cluster.leader else None
cluster_lsn = cluster.last_lsn or 0
ret: Dict[str, Any] = {'members': []}
for m in cluster.members:
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):
role = 'sync_standby'
else:
@@ -964,11 +818,11 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
member['host'] = conn_kwargs['host']
if conn_kwargs.get('port'):
member['port'] = int(conn_kwargs['port'])
optional_attributes = ('timeline', 'pending_restart', '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})
if m.name != leader_name:
lsn = m.lsn
lsn = m.data.get('xlog_location')
if lsn is None:
member['lag'] = 'unknown'
elif cluster_lsn >= lsn:
@@ -981,12 +835,13 @@ def cluster_as_json(cluster: 'Cluster') -> Dict[str, Any]:
# sort members by name for consistency
cmp: Callable[[Dict[str, Any]], bool] = lambda m: m['name']
ret['members'].sort(key=cmp)
if config.is_paused:
if global_config.is_paused:
ret['pause'] = True
if cluster.failover and cluster.failover.scheduled_at:
ret['scheduled_switchover'] = {'at': cluster.failover.scheduled_at.isoformat()}
if cluster.failover.leader:
ret['scheduled_switchover']['from'] = cluster.failover.leader
if TYPE_CHECKING: # pragma: no cover
assert cluster.failover.leader
ret['scheduled_switchover']['from'] = cluster.failover.leader
if cluster.failover.candidate:
ret['scheduled_switchover']['to'] = cluster.failover.candidate
return ret
@@ -1210,3 +1065,23 @@ def get_major_version(bin_dir: Optional[str] = None, bin_name: str = 'postgres')
if TYPE_CHECKING: # pragma: no cover
assert version is not None
return '.'.join([version.group(1), version.group(3)]) if int(version.group(1)) < 10 else version.group(1)
class ParseScheduleErrors(Enum):
NO_TIMEZONE = ('Timezone information is mandatory for the scheduled {action}', 400)
SCHEDULED_IN_PAST = ('Cannot schedule {action} in the past', 422)
PARSING_ERROR = ('Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601', 422)
def parse_schedule(schedule: Optional[str]) -> Tuple[Optional[ParseScheduleErrors], Optional[datetime.datetime]]:
scheduled_at = None
if schedule is not None:
try:
scheduled_at = dateutil.parser.parse(schedule)
if scheduled_at.tzinfo is None:
return ParseScheduleErrors.NO_TIMEZONE, scheduled_at
elif scheduled_at < datetime.datetime.now(tzutc):
return ParseScheduleErrors.SCHEDULED_IN_PAST, scheduled_at
except (ValueError, TypeError):
return ParseScheduleErrors.PARSING_ERROR, scheduled_at
return None, scheduled_at
+52 -164
View File
@@ -9,55 +9,13 @@ import os
import shutil
import socket
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple, TYPE_CHECKING
from typing import Any, Dict, Union, Iterator, List, Optional as OptionalType, Tuple
from .collections import CaseInsensitiveSet
from .collections import CaseInsensitiveSet, EMPTY_DICT
from .dcs import dcs_modules
from .exceptions import ConfigParseError
from .utils import parse_int, split_host_port, data_directory_is_empty, get_major_version
from .log import type_logformat
def validate_log_field(field: Union[str, Dict[str, Any], Any]) -> bool:
"""Checks if log field is valid.
:param field: A log field to be validated.
:returns: ``True`` if the field is either a string or a dictionary with exactly one key
that has string value, ``False`` otherwise.
"""
if isinstance(field, str):
return True
elif isinstance(field, dict):
return len(field) == 1 and isinstance(next(iter(field.values())), str)
return False
def validate_log_format(logformat: type_logformat) -> bool:
"""Checks if log format is valid.
:param logformat: A log format to be validated.
:returns: ``True`` if the log format is either a string or a list of valid log fields.
:raises:
:exc:`~patroni.exceptions.ConfigParseError`:
* If the logformat is not a string or a list; or
* If the logformat is an empty list; or
* If the log format is a list and it with values that don't pass validation using
:func:`validate_log_field`.
"""
if isinstance(logformat, str):
return True
elif isinstance(logformat, list):
if len(logformat) == 0:
raise ConfigParseError('should contain at least one item')
if not all(map(validate_log_field, logformat)):
raise ConfigParseError('each item should be a string or a dictionary with string values')
return True
else:
raise ConfigParseError('Should be a string or a list')
def data_directory_empty(data_dir: str) -> bool:
@@ -242,9 +200,7 @@ def get_bin_name(bin_name: str) -> str:
:returns: value of ``postgresql.bin_name[*bin_name*]``, if present, otherwise *bin_name*.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(schema.data, dict)
return (schema.data.get('postgresql', {}).get('bin_name', {}) or EMPTY_DICT).get(bin_name, bin_name)
return (schema.data.get('postgresql', {}).get('bin_name', {}) or {}).get(bin_name, bin_name)
def validate_data_dir(data_dir: str) -> bool:
@@ -283,8 +239,6 @@ def validate_data_dir(data_dir: str) -> bool:
if not os.path.isdir(os.path.join(data_dir, waldir)):
raise ConfigParseError("data dir for the cluster is not empty, but doesn't contain"
" \"{}\" directory".format(waldir))
if TYPE_CHECKING: # pragma: no cover
assert isinstance(schema.data, dict)
bin_dir = schema.data.get("postgresql", {}).get("bin_dir", None)
major_version = get_major_version(bin_dir, get_bin_name('postgres'))
if pgversion != major_version:
@@ -320,8 +274,6 @@ def validate_binary_name(bin_name: str) -> bool:
"""
if not bin_name:
raise ConfigParseError("is an empty string")
if TYPE_CHECKING: # pragma: no cover
assert isinstance(schema.data, dict)
bin_dir = schema.data.get('postgresql', {}).get('bin_dir', None)
if not shutil.which(bin_name, path=bin_dir):
raise ConfigParseError(f"does not contain '{bin_name}' in '{bin_dir or '$PATH'}'")
@@ -427,37 +379,6 @@ class Or(object):
self.args = args
class AtMostOne(object):
"""Mark that at most one option from a :class:`Case` can be suplied.
Represents a list of possible configuration options in a given scope, where at most one can actually
be provided.
.. note::
It should be used together with a :class:`Case` object.
"""
def __init__(self, *args: str) -> None:
"""Create a :class`AtMostOne` object.
:param `*args`: any arguments that the caller wants to be stored in this :class:`Or` object.
:Example:
.. code-block:: python
AtMostOne("nofailover", "failover_priority"): Case({
"nofailover": bool,
"failover_priority": IntValidator(min=0, raise_assert=True),
})
The :class`AtMostOne` object is used to define that at most one of ``nofailover`` and
``failover_priority`` can be provided.
"""
self.args = args
class Optional(object):
"""Mark a configuration option as optional.
@@ -571,7 +492,7 @@ class Schema(object):
* :class:`dict`: dictionary representing the YAML configuration tree.
"""
def __init__(self, validator: Union[Dict[Any, Any], List[Any], Any]) -> None:
def __init__(self, validator: Any) -> None:
"""Create a :class:`Schema` object.
.. note::
@@ -662,7 +583,7 @@ class Schema(object):
errors.append(str(i))
return errors
def validate(self, data: Union[Dict[Any, Any], Any]) -> Iterator[Result]:
def validate(self, data: Any) -> Iterator[Result]:
"""Perform all validations from the schema against the given configuration.
It first checks that *data* argument type is compliant with the type of ``validator`` attribute.
@@ -686,8 +607,11 @@ class Schema(object):
# iterable objects in the structure, until we eventually reach a leaf node to validate its value.
if isinstance(self.validator, str):
yield Result(isinstance(self.data, str), "is not a string", level=1, data=self.data)
elif isinstance(self.validator, type):
yield Result(isinstance(self.data, self.validator),
elif issubclass(type(self.validator), type):
validator = self.validator
if self.validator == str:
validator = str
yield Result(isinstance(self.data, validator),
"is not {}".format(_get_type_name(self.validator)), level=1, data=self.data)
elif callable(self.validator):
if hasattr(self.validator, "expected_type"):
@@ -734,7 +658,7 @@ class Schema(object):
for v in Schema(self.validator[0]).validate(value):
yield Result(v.status, v.error,
path=(str(key) + ("." + v.path if v.path else "")), level=v.level, data=value)
elif isinstance(self.validator, Directory) and isinstance(self.data, str):
elif isinstance(self.validator, Directory):
yield from self.validator.validate(self.data)
elif isinstance(self.validator, Or):
yield from self.iter_or()
@@ -746,13 +670,7 @@ class Schema(object):
"""
# One key in `validator` attribute (`key` variable) can be mapped to one or more keys in `data` attribute (`d`
# variable), depending on the `key` type.
if TYPE_CHECKING: # pragma: no cover
assert isinstance(self.validator, dict)
assert isinstance(self.data, dict)
for key in self.validator.keys():
if isinstance(key, AtMostOne) and len(list(self._data_key(key))) > 1:
yield Result(False, f"Multiple of {key.args} provided")
continue
for d in self._data_key(key):
if d not in self.data and not isinstance(key, Optional):
yield Result(False, "is not defined.", path=d)
@@ -762,7 +680,7 @@ class Schema(object):
if d not in self.data and isinstance(key, Optional):
self.data[d] = key.default
validator = self.validator[key]
if isinstance(key, (Or, AtMostOne)) and isinstance(self.validator[key], Case):
if isinstance(key, Or) and isinstance(self.validator[key], Case):
validator = self.validator[key]._schema[d]
# In this loop we may be calling a new `Schema` either over an intermediate node in the tree, or
# over a leaf node. In the latter case the recursive calls in the given path will finish.
@@ -778,8 +696,6 @@ class Schema(object):
:yields: objects with the error message related to the failure, if any check fails.
"""
if TYPE_CHECKING: # pragma: no cover
assert isinstance(self.validator, Or)
results: List[Result] = []
for a in self.validator.args:
r: List[Result] = []
@@ -799,7 +715,7 @@ class Schema(object):
max_level = v.level
yield Result(v.status, v.error, path=v.path, level=v.level, data=v.data)
def _data_key(self, key: Union[str, Optional, Or, AtMostOne]) -> Iterator[str]:
def _data_key(self, key: Union[str, Optional, Or]) -> Iterator[str]:
"""Map a key from the ``validator`` dictionary to the corresponding key(s) in the ``data`` dictionary.
:param key: key from the ``validator`` attribute.
@@ -816,26 +732,18 @@ class Schema(object):
yield key.name
# If the key was defined as an `Or` object in `validator` attribute, then each of its values are the keys to
# access the `data` dictionary.
elif isinstance(key, Or) and isinstance(self.data, dict):
elif isinstance(key, Or):
# At least one of the `Or` entries should be available in the `data` dictionary. If we find at least one of
# them in `data`, then we return all found entries so the caller method can validate them all.
if any([item in self.data for item in key.args]):
for item in key.args:
if item in self.data:
yield item
if any([i in self.data for i in key.args]):
for i in key.args:
if i in self.data:
yield i
# If none of the `Or` entries is available in the `data` dictionary, then we return all entries so the
# caller method will issue errors that they are all absent.
else:
for item in key.args:
yield item
# If the key was defined as a `AtMostOne` object in `validator` attribute, then each of its values
# are the keys to access the `data` dictionary.
elif isinstance(key, AtMostOne) and isinstance(self.data, dict):
# Yield back all of the entries from the `data` dictionary, each will be validated and then counted
# to inform us if we've provided too many
for item in key.args:
if item in self.data:
yield item
for i in key.args:
yield i
def _get_type_name(python_type: Any) -> str:
@@ -864,28 +772,27 @@ def assert_(condition: bool, message: str = "Wrong value") -> None:
class IntValidator(object):
"""Validate an integer setting.
:cvar expected_type: the expected Python type for an integer setting (:class:`int`).
:ivar min: minimum allowed value for the setting, if any.
:ivar max: maximum allowed value for the setting, if any.
:ivar base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
:ivar expected_type: the expected Python type.
:ivar raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
"""
expected_type = int
def __init__(self, min: OptionalType[int] = None, max: OptionalType[int] = None,
base_unit: OptionalType[str] = None, expected_type: Any = None, raise_assert: bool = False) -> None:
base_unit: OptionalType[str] = None, raise_assert: bool = False) -> None:
"""Create an :class:`IntValidator` object with the given rules.
:param min: minimum allowed value for the setting, if any.
:param max: maximum allowed value for the setting, if any.
:param base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
:param expected_type: the expected Python type.
:param raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
"""
self.min = min
self.max = max
self.base_unit = base_unit
if expected_type:
self.expected_type = expected_type
self.raise_assert = raise_assert
def __call__(self, value: Any) -> bool:
@@ -979,20 +886,6 @@ validate_etcd = {
schema = Schema({
"name": str,
"scope": str,
Optional("log"): {
Optional("type"): EnumValidator(('plain', 'json'), case_sensitive=True, raise_assert=True),
Optional("level"): EnumValidator(('DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR', 'FATAL', 'CRITICAL'),
case_sensitive=True, raise_assert=True),
Optional("traceback_level"): EnumValidator(('DEBUG', 'ERROR'), raise_assert=True),
Optional("format"): validate_log_format,
Optional("dateformat"): str,
Optional("static_fields"): dict,
Optional("max_queue_size"): int,
Optional("dir"): str,
Optional("file_num"): int,
Optional("file_size"): int,
Optional("loggers"): dict
},
Optional("ctl"): {
Optional("insecure"): bool,
Optional("cacert"): str,
@@ -1018,36 +911,36 @@ schema = Schema({
Optional("allowlist_include_members"): bool,
Optional("http_extra_headers"): dict,
Optional("https_extra_headers"): dict,
Optional("request_queue_size"): IntValidator(min=0, max=4096, expected_type=int, raise_assert=True)
Optional("request_queue_size"): IntValidator(min=0, max=4096, raise_assert=True)
},
Optional("bootstrap"): {
"dcs": {
Optional("ttl"): IntValidator(min=20, raise_assert=True),
Optional("loop_wait"): IntValidator(min=1, raise_assert=True),
Optional("retry_timeout"): IntValidator(min=3, raise_assert=True),
Optional("maximum_lag_on_failover"): IntValidator(min=0, raise_assert=True),
Optional("maximum_lag_on_syncnode"): IntValidator(min=-1, raise_assert=True),
Optional("ttl"): int,
Optional("loop_wait"): int,
Optional("retry_timeout"): int,
Optional("maximum_lag_on_failover"): int,
Optional("maximum_lag_on_syncnode"): int,
Optional("postgresql"): {
Optional("parameters"): {
Optional("max_connections"): IntValidator(1, 262143, raise_assert=True),
Optional("max_locks_per_transaction"): IntValidator(10, 2147483647, raise_assert=True),
Optional("max_prepared_transactions"): IntValidator(0, 262143, raise_assert=True),
Optional("max_replication_slots"): IntValidator(0, 262143, raise_assert=True),
Optional("max_wal_senders"): IntValidator(0, 262143, raise_assert=True),
Optional("max_worker_processes"): IntValidator(0, 262143, raise_assert=True),
Optional("max_connections"): int,
Optional("max_locks_per_transaction"): int,
Optional("max_prepared_transactions"): int,
Optional("max_replication_slots"): int,
Optional("max_wal_senders"): int,
Optional("max_worker_processes"): int
},
Optional("use_pg_rewind"): bool,
Optional("pg_hba"): [str],
Optional("pg_ident"): [str],
Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True),
Optional("pg_ctl_timeout"): int,
Optional("use_slots"): bool,
},
Optional("primary_start_timeout"): IntValidator(min=0, raise_assert=True),
Optional("primary_stop_timeout"): IntValidator(min=0, raise_assert=True),
Optional("primary_start_timeout"): int,
Optional("primary_stop_timeout"): int,
Optional("standby_cluster"): {
Or("host", "port", "restore_command"): Case({
"host": str,
"port": IntValidator(max=65535, expected_type=int, raise_assert=True),
"port": int,
"restore_command": str
}),
Optional("primary_slot_name"): str,
@@ -1057,7 +950,7 @@ schema = Schema({
},
Optional("synchronous_mode"): bool,
Optional("synchronous_mode_strict"): bool,
Optional("synchronous_node_count"): IntValidator(min=1, raise_assert=True),
Optional("synchronous_node_count"): int
},
Optional("initdb"): [Or(str, dict)],
Optional("method"): str
@@ -1068,7 +961,7 @@ schema = Schema({
"host": validate_host_port,
"url": str
}),
Optional("port"): IntValidator(max=65535, expected_type=int, raise_assert=True),
Optional("port"): int,
Optional("scheme"): str,
Optional("token"): str,
Optional("verify"): bool,
@@ -1088,8 +981,8 @@ schema = Schema({
"etcd3": validate_etcd,
"exhibitor": {
"hosts": [str],
"port": IntValidator(max=65535, expected_type=int, raise_assert=True),
Optional("poll_interval"): IntValidator(min=1, expected_type=int, raise_assert=True),
"port": IntValidator(max=65535, raise_assert=True),
Optional("pool_interval"): int
},
"raft": {
"self_addr": validate_connect_address,
@@ -1106,8 +999,7 @@ schema = Schema({
Optional("key"): str,
Optional("key_password"): str,
Optional("verify"): bool,
Optional("set_acls"): dict,
Optional("auth_data"): dict,
Optional("set_acls"): dict
},
"kubernetes": {
"labels": {},
@@ -1121,14 +1013,14 @@ schema = Schema({
Optional("tmp_role_label"): str,
Optional("use_endpoints"): bool,
Optional("pod_ip"): Or(is_ipv4_address, is_ipv6_address),
Optional("ports"): [{"name": str, "port": IntValidator(max=65535, expected_type=int, raise_assert=True)}],
Optional("ports"): [{"name": str, "port": int}],
Optional("cacert"): str,
Optional("retriable_http_codes"): Or(int, [int]),
},
}),
Optional("citus"): {
"database": str,
"group": IntValidator(min=0, expected_type=int, raise_assert=True),
"group": int
},
"postgresql": {
"listen": validate_host_port_listen_multiple_hosts,
@@ -1155,23 +1047,19 @@ schema = Schema({
},
Optional("pg_hba"): [str],
Optional("pg_ident"): [str],
Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True),
Optional("pg_ctl_timeout"): int,
Optional("use_pg_rewind"): bool
},
Optional("watchdog"): {
Optional("mode"): validate_watchdog_mode,
Optional("device"): str,
Optional("safety_margin"): IntValidator(min=-1, expected_type=int, raise_assert=True),
Optional("safety_margin"): int
},
Optional("tags"): {
AtMostOne("nofailover", "failover_priority"): Case({
"nofailover": bool,
"failover_priority": IntValidator(min=0, expected_type=int, raise_assert=True),
}),
Optional("nofailover"): bool,
Optional("clonefrom"): bool,
Optional("noloadbalance"): bool,
Optional("replicatefrom"): str,
Optional("nosync"): bool,
Optional("nostream"): bool
Optional("nosync"): bool
}
})
+1 -1
View File
@@ -2,4 +2,4 @@
:var __version__: the current Patroni version.
"""
__version__ = '3.3.0'
__version__ = '3.1.0'
+11 -8
View File
@@ -43,13 +43,9 @@ etcd:
# - 127.0.0.1:2223
# - 127.0.0.1:2224
# The bootstrap configuration. Works only when the cluster is not yet initialized.
# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!
bootstrap:
# This section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
# and all other cluster members will use it as a `global configuration`.
# WARNING! If you want to change any of the parameters that were set up
# via `bootstrap.dcs` section, please use `patronictl edit-config`!
# this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
# and all other cluster members will use it as a `global configuration`
dcs:
ttl: 30
loop_wait: 10
@@ -97,6 +93,14 @@ bootstrap:
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
# post_init: /usr/local/bin/setup_cluster.sh
# Some additional users which needs to be created after initializing new cluster
users:
admin:
password: admin%
options:
- createrole
- createdb
postgresql:
listen: 127.0.0.1:5432
connect_address: 127.0.0.1:5432
@@ -132,8 +136,7 @@ postgresql:
# safety_margin: 5
tags:
# failover_priority: 1
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
nostream: false

Some files were not shown because too many files have changed in this diff Show More