Compare commits

..
Author SHA1 Message Date
Alexander KukushkinandPolina Bungina c8e32775df Release v3.2.2 (#3007)
- update release notes
- bump Patroni version
- bump pyright version and fix reported issues
- improve compatibility with legacy psycopg2

Co-authored-by: Polina Bungina <[email protected]>
2024-01-17 08:35:35 +01:00
Polina BunginaandAlexander Kukushkin f2919f9c2f Fixes around pending_restart flag (#3003)
* Do not set pending_restart flag if hot_standby is set to 'off' during a custom bootstrap (even though we will have this flag actually set in PG, this configuration parameter is irrelevant on primary and there is no actual need for restart)
* Skip hot_standby and wal_log_hints when querying parameters pending restart on config reload. They actually can be changed manually (e.g. via ALTER SYSTEM) and it will cause the pending_restart state in PG but Patroni anyway always passes those params to postmaster as command line options. And there they only can have one value - 'on' (except on primary when performing custom bootstrap)
2024-01-16 10:44:30 +01:00
Alexander Kukushkin f59c79740f Optimize priority failover behave tests (#3004)
1. get rid of useless sleep calls
2. call `POST /failover` on the node where we want to failover to
2024-01-15 12:24:42 +01:00
Alexander Kukushkin 2a64bfd459 Restore recovery GUCs when joining running standby (#2998)
Close https://github.com/zalando/patroni/issues/2993
2024-01-08 09:17:17 +01:00
IsraelandAlexander Kukushkin 23067d7ea7 Close the doors for a possible future bug in the config generator (#3000)
The `AbstractConfigGenerator._format_config` method was missing a comma in the declaration of a tuple. As a consequence it was concatenating the strings `ctl` and `citus` instead of creating two separate items in the tuple.

There is currently no observed bug from that issue in the code because the template configuration created by the method `AbstractConfigGenerator.get_template_config` doesn't include either of `ctl` or `citus` keys.

However, it is still important that we close the doors for possible future bugs that would come up if we ever attempt to use either of those keys in the template, for example.

References: PAT-231.
2024-01-05 10:17:07 +01:00
Sophia RuanandAlexander Kukushkin 47063de46d call freeze_support in main module to solve pyinstaller frozen issue (#2996)
Close #2995
2024-01-05 10:17:01 +01:00
Polina BunginaandAlexander Kukushkin 3e9bceac11 Don't filter out contradictory nofailover tag (#2992)
* Ensure that nofailover will always be used if both nofailover and
failover_priority tags are provided
* Call _validate_failover_tags from reload_local_configuration() as well
* Properly check values in the _validate_failover_tags(): nofailover value should be casted to boolean like it is done when accessed in other places
2024-01-05 10:16:52 +01:00
zhjwpkuandAlexander Kukushkin 9cc1f8e763 Fix Citus bootstrap - CREATE DATABASE cannot be executed from a function (#2994)
This was introduced by #2990: pod cannot be started and show the
following logs:

```
2023-12-26 03:29:25.569 UTC [47] CONTEXT:  SQL statement "CREATE DATABASE "citus""
        PL/pgSQL function inline_code_block line 5 at SQL statement
2023-12-26 03:29:25.569 UTC [47] STATEMENT:  DO $$
        BEGIN
            PERFORM * FROM pg_catalog.pg_database WHERE datname = 'citus';
            IF NOT FOUND THEN
                CREATE DATABASE "citus";
            END IF;
        END;$$
2023-12-26 03:29:25,570 ERROR: post_bootstrap
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/dist-packages/patroni/postgresql/bootstrap.py", line 474, in post_bootstrap
    self._postgresql.citus_handler.bootstrap()
  File "/usr/local/lib/python3.11/dist-packages/patroni/postgresql/mpp/citus.py", line 401, in bootstrap
    cur.execute(sql.encode('utf-8'))
psycopg2.errors.ActiveSqlTransaction: CREATE DATABASE cannot be executed from a function
CONTEXT:  SQL statement "CREATE DATABASE "citus""
PL/pgSQL function inline_code_block line 5 at SQL statement
```
---------

Signed-off-by: Zhao Junwang <[email protected]>
2024-01-05 10:16:26 +01:00
Alexander Kukushkin d00f5a645b Create citus database and extension idempotently (#2990)
Consider a task: we want to create an extension _before_ citus in a database. Currently `post_bootstrab` script is executed before `CitusHandler.bootstrap()` method, which seems to allow doing that, but in fact `CitusHandler.bootstrap()` will fail to create already existing database and as a result the whole bootstrap will fail.

Changing the order of execution of `post_bootstrab` hook and `CitusHandler.bootstrap()` seems to be useless, because it will not allow creating another extension _before_ citus. Therefore the only way of solving it is making CREATE DATABASE and CREATE EXTENSION idempotent. It will allow to create citus database and all dependencies from the `post_bootstrab` hook.
2024-01-05 10:14:45 +01:00
Polina BunginaandAlexander Kukushkin 15b57c5bdc Exclude leader from failover candidates in ctl (#2983)
Exclude actual leader (not the passed leader argument) from the
candidates list in the `patronictl failover` prompt.
Abort `patronictl failover` execution if candidate specified is
the same as the current cluster leader
2024-01-05 10:12:33 +01:00
Polina BunginaandAlexander Kukushkin f10e4805db Actually allow failover to an async candidate in sync mode (#2980) 2024-01-05 10:05:28 +01:00
Polina BunginaandAlexander Kukushkin 3e0e91f905 Reload postgres config if a server param was reset (#2975)
Fix the case when a parameter value was changed and then reset back to
the initial value without restart - before this fix, the second change
was not reflected in the Postgres config.
This commit also includes the related unit test refactoring.
2024-01-05 09:56:01 +01:00
Alexander Kukushkin 51a148fcf3 Use consistent read when fetching just updated sync key (#2974)
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. By default stale reads are allowed and sometimes we may read stale data. As a result write_sync_state() call was considered as failed. To mitigate the problem we switch to `consistent` reads when that executed after update of the `/sync` key.

Close #2972
2024-01-05 09:55:43 +01:00
Alexander Kukushkin c3697738b1 Disable SSL for MacOS GH action runners (#2976)
Latest runners release (20231127.1) somehow broke our tests. Connections to postgres somehow failing with strange error:
```
could not accept SSL connection: Socket operation on non-socket
```
2024-01-05 09:55:27 +01:00
Alexander Kukushkin 722b4b72a8 Don't let replica restore initialize key when DCS was wiped (#2970)
It was happening from the branch where Patroni was supposed to be complain about converting standalone PG cluster to be governed by Patroni and exit.
2024-01-05 09:55:10 +01:00
Alexander Kukushkin 65b43c39fa Release/v3.2.1 (#2968)
- bump version
- bump pyright
- update release notes
2023-11-30 16:51:21 +01:00
WaynervandAlexander Kukushkin aea9a2b0ca Cache postgres --describe-config output results (#2967)
We don't expect GUCs list to change for the same major version and don't expect major version to change while Patroni is running.
2023-11-30 12:07:06 +01:00
Sophia RuanandAlexander Kukushkin 71ccd41915 Fix the issue that REST API returns unknown after postgres restart (#2956)
Close #2955
2023-11-30 10:16:51 +01:00
Alexander Kukushkin 49e4a6ed7d Fix Citus transaction rollback condition check (#2964)
It seems that sometimes we get an exact match, what makes behave tests to fail.
2023-11-30 09:02:50 +01:00
Alexander Kukushkin ebd05871d9 Bump pyright to 1.1.336 (#2952)
and fix newly reported issues
2023-11-30 09:02:16 +01:00
Alexander Kukushkin 42cd803619 Fix bug with custom bootstrap (#2948)
Patroni was falsely applying `--command` argument.

Close https://github.com/zalando/patroni/issues/2947
2023-11-30 09:01:47 +01:00
Alexander Kukushkin bae72df5b1 Fix pg_rewind behavior with Postgres v16+ (#2944)
The error message format was changed in
https://github.com/postgres/postgres/commit/4ac30ba4f29d4b586b131404b0d514f16501272a, what caused `pg_rewind` being called by Patroni even when it was not necessary.
2023-11-30 09:01:41 +01:00
Alexander Kukushkin f2a129f209 Fix Etcd v2 with Citus (#2943)
When deploying a new Citus cluster with Etcd v2 Patroni was failing to start with the following exception:
```python
2023-11-09 10:51:41,246 INFO: Selected new etcd server http://localhost:2379
Traceback (most recent call last):
  File "/home/akukushkin/git/patroni/./patroni.py", line 6, in <module>
    main()
  File "/home/akukushkin/git/patroni/patroni/__main__.py", line 343, in main
    return patroni_main(args.configfile)
  File "/home/akukushkin/git/patroni/patroni/__main__.py", line 237, in patroni_main
    abstract_main(Patroni, configfile)
  File "/home/akukushkin/git/patroni/patroni/daemon.py", line 172, in abstract_main
    controller = cls(config)
  File "/home/akukushkin/git/patroni/patroni/__main__.py", line 66, in __init__
    self.ensure_unique_name()
  File "/home/akukushkin/git/patroni/patroni/__main__.py", line 112, in ensure_unique_name
    cluster = self.dcs.get_cluster()
  File "/home/akukushkin/git/patroni/patroni/dcs/__init__.py", line 1654, in get_cluster
    cluster = self._get_citus_cluster() if self.is_citus_coordinator() else self.__get_patroni_cluster()
  File "/home/akukushkin/git/patroni/patroni/dcs/__init__.py", line 1638, in _get_citus_cluster
    cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
AttributeError: 'Cluster' object has no attribute 'pop'
```

It is broken since #2909.

In addition to that fix `_citus_cluster_loader()` interface by allowing it to return only dict obj.
2023-11-30 09:01:19 +01:00
Alexander Kukushkin df0fd91614 Do a real http request when performing name uniqueness check (#2942)
When running in containers it is possible that the traffic is routed using `docker-proxy`, which listens on the port and accepting incoming connections.

This commit effectively sticks to the original solution from #2878
2023-11-30 09:01:11 +01:00
Alexander Kukushkin 43f23df974 Verify that replica nodes received checkpoint LSN on shutdown (#2939)
In case if archiving is enabled the `Postgresql.latest_checkpoint_location()` method returns LSN of the prev (SWITCH) record, which points to the beginning of the WAL file. It is done in order to make it possible to safely promote replica which recovers WAL files from the archive and wasn't streaming when the primary was stopped (primary doesn't archive this WAL file).

But, in certain cases using the LSN pointing to SWITCH record was causing unnecessary pg_rewind, if replica didn't managed to replay shutdown checkpoint record before it was promoted.

In order to mitigate the problem we need to check that replica received/replayed exactly the shutdown checkpoint LSN. But, at the same time we will still write LSN of the SWITCH record to the `/status` key when releasing the leader lock.
2023-11-30 09:01:05 +01:00
Alexander Kukushkin 42bf1f95a3 Limit accepted values for --format argument (#2938)
It used to accept any arbitrary string

Close https://github.com/zalando/patroni/issues/2936
2023-11-30 09:00:39 +01:00
IsraelandAlexander Kukushkin 23200daada Add a FAQ page to the docs (#2933)
This commit introduces a FAQ page to the docs. The idea is to get
most frequently asked questions answered before-hand, so the user
is able to get them answered quickly without going into detail in
the docs or having to go to Slack/GitHub to clarify questions.

---------
Signed-off-by: Israel Barth Rubio <[email protected]>
2023-11-30 09:00:22 +01:00
48 changed files with 1353 additions and 2060 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ jobs:
- uses: jakebailey/pyright-action@v1
with:
version: 1.1.338
version: 1.1.347
docs:
runs-on: ubuntu-latest
+1 -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
+3 -4
View File
@@ -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 {} \; \
@@ -143,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/
+3 -4
View File
@@ -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/
+1 -1
View File
@@ -151,7 +151,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}
+167 -167
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
/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
-1
View File
@@ -85,7 +85,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.
+50
View File
@@ -3,6 +3,56 @@
Release notes
=============
Version 3.2.2
-------------
**Bugfixes**
- Don't let replica restore initialize key when DCS was wiped (Alexander Kukushkin)
It was happening in the method where Patroni was supposed to take over a standalone PG cluster.
- Use consistent read when fetching just updated sync key from Consul (Alexander Kukushkin)
Consul doesn't provide any interface to immediately get ``ModifyIndex`` for the key that we just updated, therefore we have to perform an explicit read operation. Since stale reads are allowed by default, we sometimes used to get an outdated version of the key.
- Reload Postgres config if a parameter that requires restart was reset to the original value (Polina Bungina)
Previously Patroni wasn't updating the config, but only resetting the ``pending_restart``.
- Fix erroneous inverted logic of the confirmation prompt message when doing a failover to an async candidate in synchronous mode (Polina Bungina)
The problem existed only in ``patronictl``.
- Exclude leader from failover candidates in ``patronictl`` (Polina Bungina)
If the cluster is healthy, failing over to an existing leader is no-op.
- Create Citus database and extension idempotently (Alexander Kukushkin, Zhao Junwang)
It will allow to create them in the ``post_bootstrap`` script in case if there is a need to add some more dependencies to the Citus database.
- Don't filter our contradictory ``nofailover`` tag (Polina Bungina)
The configuration ``{nofailover: false, failover_priority: 0}`` set on a node didn't allow it to participate in the race, while it should, because ``nofailover`` tag should take precedence.
- Fixed PyInstaller frozen issue (Sophia Ruan)
The ``freeze_support()`` was called after ``argparse`` and as a result, Patroni wasn't able to start Postgres.
- Fixed bug in the config generator for ``patronictl`` and ``Citus`` configuration (Israel Barth Rubio)
It prevented ``patronictl`` and ``Citus`` configuration parameters set via environment variables from being written into the generated config.
- Restore recovery GUCs and some Patroni-managed parameters when joining a running standby (Alexander Kukushkin)
Patroni was failing to restart Postgres v12 onwards with an error about missing ``port`` in one of the internal structures.
- Fixes around ``pending_restart`` flag (Polina Bungina)
Don't expose ``pending_restart`` when in custom bootstrap with ``recovery_target_action = promote`` or when someone changed ``hot_standby`` or ``wal_log_hints`` using for example ``ALTER SYSTEM``.
Version 3.2.1
-------------
-35
View File
@@ -71,22 +71,6 @@ Makes the configured ``command`` to be called additionally with ``--arg1=value1
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
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_recover
api-url: https://barman-host:7480
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:
@@ -141,25 +125,6 @@ example: pgbackrest
basebackup:
max-rate: '100M'
example: Barman
.. code:: YAML
postgresql:
create_replica_methods:
- barman
- basebackup
barman:
command: patroni_barman_recover
api-url: https://barman-host:7480
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
-1
View File
@@ -133,7 +133,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.
+13 -8
View File
@@ -6,10 +6,9 @@ Feature: priority replication
And I configure and start postgres1 with a tag failover_priority 0
Then replication works from postgres0 to postgres1 after 20 seconds
When I shut down postgres0
And I sleep for 5 seconds
Then postgres1 role is the secondary after 10 seconds
And there is one of ["following a different leader because I am not allowed to promote"] INFO in the postgres1 patroni log after 5 seconds
Given I start postgres0
Then postgres1 role is the secondary after 10 seconds
When I start postgres0
Then postgres0 role is the primary after 10 seconds
Scenario: check higher failover priority is respected
@@ -18,17 +17,23 @@ Feature: priority replication
Then replication works from postgres0 to postgres2 after 20 seconds
And replication works from postgres0 to postgres3 after 20 seconds
When I shut down postgres0
And I sleep for 5 seconds
Then postgres3 role is the primary after 10 seconds
And there is one of ["postgres3 has equally tolerable WAL position and priority 2, while this node has priority 1","Wal position of postgres3 is ahead of my wal position"] INFO in the postgres2 patroni log after 5 seconds
Scenario: check conflicting configuration handling
Scenario: check conflicting configuration handling
When I set nofailover tag in postgres2 config
And I issue an empty POST request to http://127.0.0.1:8010/reload
Then I receive a response code 202
And there is one of ["Conflicting configuration between nofailover: True and failover_priority: 1. Defaulting to nofailover: True"] WARNING in the postgres2 patroni log after 5 seconds
When I issue a GET request to http://127.0.0.1:8010/patroni
Then I receive a response tags {'nofailover': True}
And "members/postgres2" key in DCS has tags={'failover_priority': '1', 'nofailover': True} after 10 seconds
When I issue a POST request to http://127.0.0.1:8010/failover with {"candidate": "postgres2"}
Then I receive a response code 412
Then I receive a response code 412
And I receive a response text "failover is not possible: no good candidates have been found"
When I reset nofailover tag in postgres1 config
And I issue an empty POST request to http://127.0.0.1:8009/reload
Then I receive a response code 202
And there is one of ["Conflicting configuration between nofailover: False and failover_priority: 0. Defaulting to nofailover: False"] WARNING in the postgres1 patroni log after 5 seconds
And "members/postgres1" key in DCS has tags={'failover_priority': '0', 'nofailover': False} after 10 seconds
And I issue a POST request to http://127.0.0.1:8009/failover with {"candidate": "postgres1"}
Then I receive a response code 200
And postgres1 role is the primary after 10 seconds
+1 -1
View File
@@ -114,7 +114,7 @@ def replication_works(context, primary, replica, time_limit):
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
@then('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
@step('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
def check_patroni_log(context, message_list, level, node, timeout):
timeout *= context.timeout_multiplier
message_list = json.loads(message_list)
+4 -3
View File
@@ -128,9 +128,10 @@ def scheduled_restart(context, url, in_seconds, data):
context.execute_steps(u"""Given I issue a POST request to {0}/restart with {1}""".format(url, json.dumps(data)))
@step('I set {tag:w} tag in {pg_name:w} config')
def add_bool_tag_to_config(context, tag, pg_name):
context.pctl.add_tag_to_config(pg_name, tag, True)
@step('I {action:w} {tag:w} tag in {pg_name:w} config')
def add_bool_tag_to_config(context, action, tag, pg_name):
value = action == 'set'
context.pctl.add_tag_to_config(pg_name, tag, value)
@step('I add tag {tag:w} {value:w} to {pg_name:w} config')
+6 -5
View File
@@ -229,11 +229,6 @@ def patroni_main(configfile: str) -> None:
:param configfile: path to Patroni configuration file.
"""
from multiprocessing import freeze_support
# Windows executables created by PyInstaller are frozen, thus we need to enable frozen support for
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
freeze_support()
abstract_main(Patroni, configfile)
@@ -335,6 +330,12 @@ def main() -> None:
``patroni`` daemon as another process. In that case relevant signals received by the main process and forwarded
to ``patroni`` daemon process.
"""
from multiprocessing import freeze_support
# Executables created by PyInstaller are frozen, thus we need to enable frozen support for
# :mod:`multiprocessing` to avoid :class:`RuntimeError` exceptions.
freeze_support()
check_psycopg()
args = process_arguments()
+14 -13
View File
@@ -26,7 +26,7 @@ from urllib.parse import urlparse, parse_qs
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union
from . import global_config, psycopg
from . import psycopg
from .__main__ import Patroni
from .dcs import Cluster
from .exceptions import PostgresConnectionException, PostgresException
@@ -290,7 +290,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)
@@ -308,7 +308,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:
@@ -452,8 +452,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
HTTP status ``200`` and the JSON representation of the cluster topology.
"""
cluster = self.server.patroni.dcs.get_cluster()
global_config = self.server.patroni.config.get_global_config(cluster)
response = cluster_as_json(cluster)
response = cluster_as_json(cluster, global_config)
response['scope'] = self.server.patroni.postgresql.scope
self._write_json_response(200, response)
@@ -863,7 +864,7 @@ 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
@@ -1032,7 +1033,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
: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
is_synchronous_mode = self.server.patroni.config.get_global_config(cluster).is_synchronous_mode
if leader and (not cluster.leader or cluster.leader.name != leader):
return 'leader name does not match'
if candidate:
@@ -1090,7 +1091,7 @@ 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)
@@ -1103,12 +1104,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
if not data and scheduled_at:
if action == 'failover':
data = "Failover can't be scheduled"
elif config.is_paused:
elif global_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:
if not data and global_config.is_paused and not candidate:
data = 'Switchover is possible only to a specific candidate in a paused state'
if action == 'failover' and leader:
@@ -1259,7 +1260,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'):
@@ -1290,10 +1291,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
@@ -1318,7 +1319,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
+182 -18
View File
@@ -12,7 +12,7 @@ from typing import Any, Callable, Collection, Dict, List, Optional, Union, TYPE_
from . import PATRONI_ENV_PREFIX
from .collections import CaseInsensitiveDict
from .dcs import ClusterConfig
from .dcs import ClusterConfig, Cluster
from .exceptions import ConfigParseError
from .file_perm import pg_perm
from .postgresql.config import ConfigHandler
@@ -54,6 +54,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 and it is not a standby cluster config."""
return self.check_mode('synchronous_mode') and not self.is_standby_cluster
@property
def is_synchronous_mode_strict(self) -> bool:
"""``True`` if at least one synchronous node is required."""
return self.check_mode('synchronous_mode_strict')
def get_standby_cluster_config(self) -> Union[Dict[str, Any], Any]:
"""Get ``standby_cluster`` configuration.
:returns: a copy of ``standby_cluster`` configuration.
"""
return deepcopy(self.get('standby_cluster'))
@property
def is_standby_cluster(self) -> bool:
"""``True`` if global configuration has a valid ``standby_cluster`` section."""
config = self.get_standby_cluster_config()
return isinstance(config, dict) and\
bool(config.get('host') or config.get('port') or config.get('restore_command'))
def get_int(self, name: str, default: int = 0) -> int:
"""Gets current value of *name* from the global configuration and try to return it as :class:`int`.
:param name: name of the parameter.
:param default: default value if *name* is not in the configuration or invalid.
:returns: currently configured value of *name* from the global configuration or *default* if it is not set or
invalid.
"""
ret = parse_int(self.get(name))
return default if ret is None else ret
@property
def min_synchronous_nodes(self) -> int:
"""The minimal number of synchronous nodes based on whether ``synchronous_mode_strict`` is enabled or not."""
return 1 if self.is_synchronous_mode_strict else 0
@property
def synchronous_node_count(self) -> int:
"""Currently configured value of ``synchronous_node_count`` from the global configuration.
Assume ``1`` if it is not set or invalid.
"""
return max(self.get_int('synchronous_node_count', 1), self.min_synchronous_nodes)
@property
def maximum_lag_on_failover(self) -> int:
"""Currently configured value of ``maximum_lag_on_failover`` from the global configuration.
Assume ``1048576`` if it is not set or invalid.
"""
return self.get_int('maximum_lag_on_failover', 1048576)
@property
def maximum_lag_on_syncnode(self) -> int:
"""Currently configured value of ``maximum_lag_on_syncnode`` from the global configuration.
Assume ``-1`` if it is not set or invalid.
"""
return self.get_int('maximum_lag_on_syncnode', -1)
@property
def primary_start_timeout(self) -> int:
"""Currently configured value of ``primary_start_timeout`` from the global configuration.
Assume ``300`` if it is not set or invalid.
.. note::
``master_start_timeout`` is still supported to keep backward compatibility.
"""
default = 300
return self.get_int('primary_start_timeout', default)\
if 'primary_start_timeout' in self.__config else self.get_int('master_start_timeout', default)
@property
def primary_stop_timeout(self) -> int:
"""Currently configured value of ``primary_stop_timeout`` from the global configuration.
Assume ``0`` if it is not set or invalid.
.. note::
``master_stop_timeout`` is still supported to keep backward compatibility.
"""
default = 0
return self.get_int('primary_stop_timeout', default)\
if 'primary_stop_timeout' in self.__config else self.get_int('master_stop_timeout', default)
def get_global_config(cluster: Optional[Cluster], default: Optional[Dict[str, Any]] = None) -> GlobalConfig:
"""Instantiates :class:`GlobalConfig` based on the input.
:param cluster: the currently known cluster state from DCS.
:param default: default configuration, which will be used if there is no valid *cluster.config*.
:returns: :class:`GlobalConfig` object.
"""
# Try to protect from the case when DCS was wiped out
if cluster and cluster.config and cluster.config.modify_version:
config = cluster.config.data
else:
config = default or {}
return GlobalConfig(deepcopy(config))
class Config(object):
"""Handle Patroni configuration.
@@ -142,8 +290,9 @@ class Config(object):
self.__effective_configuration = self._build_effective_configuration({}, self._local_configuration)
self._data_dir = self.__effective_configuration.get('postgresql', {}).get('data_dir', "")
self._cache_file = os.path.join(self._data_dir, self.__CACHE_FILENAME)
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
self._load_cache()
if validator: # patronictl uses validator=None
self._load_cache() # we don't want to load anything from local cache for ctl
self._validate_failover_tags() # irrelevant for ctl
self._cache_needs_saving = False
@property
@@ -355,6 +504,7 @@ class Config(object):
new_configuration = self._build_effective_configuration(self._dynamic_configuration, configuration)
self._local_configuration = configuration
self.__effective_configuration = new_configuration
self._validate_failover_tags()
return True
else:
logger.info('No local configuration items changed.')
@@ -642,7 +792,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':
@@ -653,7 +803,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)
@@ -745,11 +895,14 @@ class Config(object):
dcs = bootstrap.setdefault('dcs', {})
dcs.setdefault('synchronous_mode', True)
if 'tags' in config:
self._validate_failover_tags(config['tags'])
updated_fields = (
'name',
'scope',
'retry_timeout',
'citus'
)
# Add params required inside Postgresql class to PG config
pg_config.update({p: config[p] for p in ('name', 'scope', 'retry_timeout', 'citus') if p in config})
pg_config.update({p: config[p] for p in updated_fields if p in config})
return config
@@ -797,11 +950,20 @@ class Config(object):
"""
return deepcopy(self.__effective_configuration)
@staticmethod
def _validate_failover_tags(tags_config: Dict[str, Any]) -> None:
"""Check ``nofailover``/``failover_priority`` config, remove contradictory tag and warn user.
def get_global_config(self, cluster: Optional[Cluster]) -> GlobalConfig:
"""Instantiate :class:`GlobalConfig` based on input.
:param tags_config: dictionary representing values under the ``tags`` configuration section.
Use the configuration from provided *cluster* (the most up-to-date) or from the
local cache if *cluster.config* is not initialized or doesn't have a valid config.
:param cluster: the currently known cluster state from DCS.
:returns: :class:`GlobalConfig` object.
"""
return get_global_config(cluster, self._dynamic_configuration)
def _validate_failover_tags(self) -> None:
"""Check ``nofailover``/``failover_priority`` config and warn user if it's contradictory.
.. note::
To preserve sanity (and backwards compatibility) the ``nofailover`` tag will still exist. A contradictory
@@ -812,11 +974,13 @@ class Config(object):
The behaviour is as if ``failover_priority`` were not provided (i.e ``nofailover`` is the
bedrock source of truth)
"""
nofailover_tag = tags_config.get('nofailover')
failover_priority_tag = parse_int(tags_config.get('failover_priority'))
tags = self.get('tags', {})
if 'nofailover' not in tags:
return
nofailover_tag = tags.get('nofailover')
failover_priority_tag = parse_int(tags.get('failover_priority'))
if failover_priority_tag is not None \
and (nofailover_tag is True and failover_priority_tag > 0
or nofailover_tag is False and failover_priority_tag <= 0):
and (bool(nofailover_tag) is True and failover_priority_tag > 0
or bool(nofailover_tag) is False and failover_priority_tag <= 0):
logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. '
'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag)
tags_config.pop('failover_priority')
+1 -1
View File
@@ -178,7 +178,7 @@ class AbstractConfigGenerator(abc.ABC):
:yields: formatted lines or blocks that represent a text output of the YAML document.
"""
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl' 'citus',
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl', 'citus',
'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'):
yield from self._format_config_section(name)
+164 -135
View File
@@ -46,8 +46,7 @@ try:
except ImportError: # pragma: no cover
from cdiff import markup_to_pager, PatchStream # pyright: ignore [reportMissingModuleSource]
from . import global_config
from .config import Config
from .config import Config, get_global_config
from .dcs import get_dcs as _get_dcs, AbstractDCS, Cluster, Member
from .exceptions import PatroniException
from .postgresql.misc import postgres_version_to_int
@@ -256,23 +255,15 @@ def load_config(path: str, dcs_url: Optional[str]) -> Dict[str, Any]:
return config
def _get_configuration() -> Dict[str, Any]:
"""Get configuration object.
:returns: configuration object from the current context.
"""
return click.get_current_context().obj['__config']
option_format = click.option('--format', '-f', 'fmt', help='Output format', default='pretty',
type=click.Choice(['pretty', 'tsv', 'json', 'yaml', 'yml']))
option_watchrefresh = click.option('-w', '--watch', type=float, help='Auto update the screen every X seconds')
option_watch = click.option('-W', is_flag=True, help='Auto update the screen every 2 seconds')
option_force = click.option('--force', is_flag=True, help='Do not ask for confirmation at any point')
arg_cluster_name = click.argument('cluster_name', required=False,
default=lambda: _get_configuration().get('scope'))
default=lambda: click.get_current_context().obj.get('scope'))
option_default_citus_group = click.option('--group', required=False, type=int, help='Citus group',
default=lambda: _get_configuration().get('citus', {}).get('group'))
default=lambda: click.get_current_context().obj.get('citus', {}).get('group'))
option_citus_group = click.option('--group', required=False, type=int, help='Citus group')
role_choice = click.Choice(['leader', 'primary', 'standby-leader', 'replica', 'standby', 'any', 'master'])
@@ -310,23 +301,15 @@ def ctl(ctx: click.Context, config_file: str, dcs_url: Optional[str], insecure:
level = os.environ.get(name, level)
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=level)
logging.captureWarnings(True) # Capture eventual SSL warning
config = load_config(config_file, dcs_url)
ctx.obj = load_config(config_file, dcs_url)
# backward compatibility for configuration file where ctl section is not defined
config.setdefault('ctl', {})['insecure'] = config.get('ctl', {}).get('insecure') or insecure
ctx.obj = {'__config': config}
ctx.obj.setdefault('ctl', {})['insecure'] = ctx.obj.get('ctl', {}).get('insecure') or insecure
def is_citus_cluster() -> bool:
"""Check if we are working with Citus cluster.
:returns: ``True`` if configuration has ``citus`` section, otherwise ``False``.
"""
return bool(_get_configuration().get('citus'))
def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
def get_dcs(config: Dict[str, Any], scope: str, group: Optional[int]) -> AbstractDCS:
"""Get the DCS object.
:param config: Patroni configuration.
:param scope: cluster name.
:param group: if *group* is defined, use it to select which alternative Citus group this DCS refers to. If *group*
is ``None`` and a Citus configuration exists, assume this is the coordinator. Coordinator has the group ``0``.
@@ -337,14 +320,13 @@ def get_dcs(scope: str, group: Optional[int]) -> AbstractDCS:
:raises:
:class:`PatroniCtlException`: if not suitable DCS configuration could be found.
"""
config = _get_configuration()
config.update({'scope': scope, 'patronictl': True})
if group is not None:
config['citus'] = {'group': group}
config.setdefault('name', scope)
try:
dcs = _get_dcs(config)
if is_citus_cluster() and group is None:
if config.get('citus') and group is None:
dcs.is_citus_coordinator = lambda: True
return dcs
except PatroniException as e:
@@ -365,7 +347,7 @@ def request_patroni(member: Member, method: str = 'GET',
ctx = click.get_current_context() # the current click context
request_executor = ctx.obj.get('__request_patroni')
if not request_executor:
request_executor = ctx.obj['__request_patroni'] = PatroniRequest(_get_configuration())
request_executor = ctx.obj['__request_patroni'] = PatroniRequest(ctx.obj)
return request_executor(member, method, endpoint, data)
@@ -432,9 +414,9 @@ def print_output(columns: Optional[List[str]], rows: List[List[Any]], alignment:
def watching(w: bool, watch: Optional[int], max_count: Optional[int] = None, clear: bool = True) -> Iterator[int]:
"""Yield a value every ``watch`` seconds.
"""Yield a value every ``x`` seconds.
Used to run a command with a watch-based approach.
Used to run a command with a watch-based aproach.
:param w: if ``True`` and *watch* is ``None``, then *watch* assumes the value ``2``.
:param watch: amount of seconds to wait before yielding another value.
@@ -470,9 +452,11 @@ def watching(w: bool, watch: Optional[int], max_count: Optional[int] = None, cle
yield 0
def get_all_members(cluster: Cluster, group: Optional[int], role: str = 'leader') -> Iterator[Member]:
def get_all_members(obj: Dict[str, Any], cluster: Cluster,
group: Optional[int], role: str = 'leader') -> Iterator[Member]:
"""Get all cluster members that have the given *role*.
:param obj: the Patroni configuration.
:param cluster: the Patroni cluster.
:param group: filter which Citus group we should get members from. If ``None`` get from all groups.
:param role: role to filter members. Can be one among:
@@ -486,7 +470,7 @@ def get_all_members(cluster: Cluster, group: Optional[int], role: str = 'leader'
:yields: members that have the given *role*.
"""
clusters = {0: cluster}
if is_citus_cluster() and group is None:
if obj.get('citus') and group is None:
clusters.update(cluster.workers)
if role in ('leader', 'master', 'primary', 'standby-leader'):
# In the DCS the members' role can be one among: ``primary``, ``master``, ``replica`` or ``standby_leader``.
@@ -508,10 +492,11 @@ def get_all_members(cluster: Cluster, group: Optional[int], role: str = 'leader'
yield m
def get_any_member(cluster: Cluster, group: Optional[int],
def get_any_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int],
role: Optional[str] = None, member: Optional[str] = None) -> Optional[Member]:
"""Get the first found cluster member that has the given *role*.
:param obj: the Patroni configuration.
:param cluster: the Patroni cluster.
:param group: filter which Citus group we should get members from. If ``None`` get from all groups.
:param role: role to filter members. See :func:`get_all_members` for available options.
@@ -529,7 +514,7 @@ def get_any_member(cluster: Cluster, group: Optional[int],
elif role is None:
role = 'leader'
for m in get_all_members(cluster, group, role):
for m in get_all_members(obj, cluster, group, role):
if member is None or m.name == member:
return m
@@ -550,7 +535,7 @@ def get_all_members_leader_first(cluster: Cluster) -> Iterator[Member]:
yield member
def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[str, Any],
def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], connect_parameters: Dict[str, Any],
role: Optional[str] = None, member_name: Optional[str] = None) -> Union['cursor', 'Cursor[Any]', None]:
"""Get a cursor object to execute queries against a member that has the given *role* or *member_name*.
@@ -559,6 +544,7 @@ def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[
* ``fallback_application_name``: as ``Patroni ctl``;
* ``connect_timeout``: as ``5``.
:param obj: the Patroni configuration.
:param cluster: the Patroni cluster.
:param group: filter which Citus group we should get members to create a cursor against. If ``None`` consider
members from all groups.
@@ -573,7 +559,7 @@ def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[
* A :class:`psycopg2.extensions.cursor` if using :mod:`psycopg2`;
* ``None`` if not able to get a cursor that attendees *role* and *member_name*.
"""
member = get_any_member(cluster, group, role=role, member=member_name)
member = get_any_member(obj, cluster, group, role=role, member=member_name)
if member is None:
return None
@@ -608,7 +594,7 @@ def get_cursor(cluster: Cluster, group: Optional[int], connect_parameters: Dict[
return None
def get_members(cluster: Cluster, cluster_name: str, member_names: List[str], role: str,
def get_members(obj: Dict[str, Any], cluster: Cluster, cluster_name: str, member_names: List[str], role: str,
force: bool, action: str, ask_confirmation: bool = True, group: Optional[int] = None) -> List[Member]:
"""Get the list of members based on the given filters.
@@ -632,6 +618,7 @@ def get_members(cluster: Cluster, cluster_name: str, member_names: List[str], ro
``ask_confirmation=False``, and later call :func:`confirm_members_action` manually in the caller method. That
way the workflow won't look broken to the user that is interacting with ``patronictl``.
:param obj: Patroni configuration.
:param cluster: Patroni cluster.
:param cluster_name: name of the Patroni cluster.
:param member_names: used to filter which members should take the *action* based on their names. Each item is the
@@ -660,13 +647,13 @@ def get_members(cluster: Cluster, cluster_name: str, member_names: List[str], ro
* Cluster does not have members that match the given *member_names*; or
* No member with given *role* is found among the specified *member_names*.
"""
members = list(get_all_members(cluster, group, role))
members = list(get_all_members(obj, cluster, group, role))
candidates = {m.name for m in members}
if not force or role:
if not member_names and not candidates:
raise PatroniCtlException('{0} cluster doesn\'t have any members'.format(cluster_name))
output_members(cluster, cluster_name, group=group)
output_members(obj, cluster, cluster_name, group=group)
if member_names:
member_names = list(set(member_names) & candidates)
@@ -726,7 +713,9 @@ def confirm_members_action(members: List[Member], force: bool, action: str,
@click.option('--member', '-m', help='Generate a dsn for this member', type=str)
@arg_cluster_name
@option_citus_group
def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Optional[str]) -> None:
@click.pass_obj
def dsn(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
role: Optional[str], member: Optional[str]) -> None:
"""Process ``dsn`` command of ``patronictl`` utility.
Get DSN to connect to *member*.
@@ -734,6 +723,7 @@ def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Op
.. note::
If no *role* nor *member* is given assume *role* as ``leader``.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should get members to get DSN from. Refer to the module note for more
details.
@@ -746,8 +736,8 @@ def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Op
* both *role* and *member* are provided; or
* No member matches requested *member* or *role*.
"""
cluster = get_dcs(cluster_name, group).get_cluster()
m = get_any_member(cluster, group, role=role, member=member)
cluster = get_dcs(obj, cluster_name, group).get_cluster()
m = get_any_member(obj, cluster, group, role=role, member=member)
if m is None:
raise PatroniCtlException('Can not find a suitable member')
@@ -769,7 +759,9 @@ def dsn(cluster_name: str, group: Optional[int], role: Optional[str], member: Op
@click.option('--delimiter', help='The column delimiter', default='\t')
@click.option('--command', '-c', help='The SQL commands to execute')
@click.option('-d', '--dbname', help='database name to connect to', type=str)
@click.pass_obj
def query(
obj: Dict[str, Any],
cluster_name: str,
group: Optional[int],
role: Optional[str],
@@ -788,6 +780,7 @@ def query(
Perform a Postgres query in a Patroni node.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should get members from to perform the query. Refer to the module note for
more details.
@@ -827,22 +820,24 @@ def query(
if dbname:
connect_parameters['dbname'] = dbname
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(obj, cluster_name, group)
cluster = cursor = None
for _ in watching(w, watch, clear=False):
if cluster is None:
cluster = dcs.get_cluster()
# cursor = get_cursor(obj, cluster, group, connect_parameters, role=role, member=member)
output, header = query_member(cluster, group, cursor, member, role, sql, connect_parameters)
output, header = query_member(obj, cluster, group, cursor, member, role, sql, connect_parameters)
print_output(header, output, fmt=fmt, delimiter=delimiter)
def query_member(cluster: Cluster, group: Optional[int], cursor: Union['cursor', 'Cursor[Any]', None],
member: Optional[str], role: Optional[str], command: str,
connect_parameters: Dict[str, Any]) -> Tuple[List[List[Any]], Optional[List[Any]]]:
def query_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int],
cursor: Union['cursor', 'Cursor[Any]', None], member: Optional[str], role: Optional[str],
command: str, connect_parameters: Dict[str, Any]) -> Tuple[List[List[Any]], Optional[List[Any]]]:
"""Execute SQL *command* against a member.
:param obj: Patroni configuration.
:param cluster: the Patroni cluster.
:param group: filter which Citus group we should get members from to perform the query. Refer to the module note for
more details.
@@ -871,7 +866,7 @@ def query_member(cluster: Cluster, group: Optional[int], cursor: Union['cursor',
from . import psycopg
try:
if cursor is None:
cursor = get_cursor(cluster, group, connect_parameters, role=role, member_name=member)
cursor = get_cursor(obj, cluster, group, connect_parameters, role=role, member_name=member)
if cursor is None:
if member is not None:
@@ -898,11 +893,13 @@ def query_member(cluster: Cluster, group: Optional[int], cursor: Union['cursor',
@click.argument('cluster_name')
@option_citus_group
@option_format
def remove(cluster_name: str, group: Optional[int], fmt: str) -> None:
@click.pass_obj
def remove(obj: Dict[str, Any], cluster_name: str, group: Optional[int], fmt: str) -> None:
"""Process ``remove`` command of ``patronictl`` utility.
Remove cluster *cluster_name* from the DCS.
:param obj: Patroni configuration.
:param cluster_name: name of the cluster which information will be wiped out of the DCS.
:param group: which Citus group should have its information wiped out of the DCS. Refer to the module note for more
details.
@@ -916,12 +913,12 @@ def remove(cluster_name: str, group: Optional[int], fmt: str) -> None:
* use did not type the correct leader name when requesting removal of a healthy cluster.
"""
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(obj, cluster_name, group)
cluster = dcs.get_cluster()
if is_citus_cluster() and group is None:
if obj.get('citus') and group is None:
raise PatroniCtlException('For Citus clusters the --group must me specified')
output_members(cluster, cluster_name, fmt=fmt)
output_members(obj, cluster, cluster_name, fmt=fmt)
confirm = click.prompt('Please confirm the cluster name to remove', type=str)
if confirm != cluster_name:
@@ -1006,28 +1003,31 @@ def parse_scheduled(scheduled: Optional[str]) -> Optional[datetime.datetime]:
@option_citus_group
@click.option('--role', '-r', help='Reload only members with this role', type=role_choice, default='any')
@option_force
def reload(cluster_name: str, member_names: List[str], group: Optional[int], force: bool, role: str) -> None:
@click.pass_obj
def reload(obj: Dict[str, Any], cluster_name: str, member_names: List[str],
group: Optional[int], force: bool, role: str) -> None:
"""Process ``reload`` command of ``patronictl`` utility.
Reload configuration of cluster members based on given filters.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param member_names: name of the members which configuration should be reloaded.
:param group: filter which Citus group we should reload members. Refer to the module note for more details.
:param force: perform the reload without asking for confirmations.
:param role: role to filter members. See :func:`get_all_members` for available options.
"""
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(obj, cluster_name, group)
cluster = dcs.get_cluster()
members = get_members(cluster, cluster_name, member_names, role, force, 'reload', group=group)
members = get_members(obj, cluster, cluster_name, member_names, role, force, 'reload', group=group)
for member in members:
r = request_patroni(member, 'post', 'reload')
if r.status == 200:
click.echo('No changes to apply on member {0}'.format(member.name))
elif r.status == 202:
config = global_config.from_cluster(cluster)
config = get_global_config(cluster)
click.echo('Reload request received for member {0} and will be processed within {1} seconds'.format(
member.name, config.get('loop_wait') or dcs.loop_wait)
)
@@ -1050,13 +1050,15 @@ def reload(cluster_name: str, member_names: List[str], group: Optional[int], for
@click.option('--pending', help='Restart if pending', is_flag=True)
@click.option('--timeout', help='Return error and fail over if necessary when restarting takes longer than this.')
@option_force
def restart(cluster_name: str, group: Optional[int], member_names: List[str],
@click.pass_obj
def restart(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member_names: List[str],
force: bool, role: str, p_any: bool, scheduled: Optional[str], version: Optional[str],
pending: bool, timeout: Optional[str]) -> None:
"""Process ``restart`` command of ``patronictl`` utility.
Restart Postgres on cluster members based on given filters.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should restart members. Refer to the module note for more details.
:param member_names: name of the members that should be restarted.
@@ -1074,9 +1076,9 @@ def restart(cluster_name: str, group: Optional[int], member_names: List[str],
* *version* could not be parsed; or
* a restart is attempted against a cluster that is in maintenance mode.
"""
cluster = get_dcs(cluster_name, group).get_cluster()
cluster = get_dcs(obj, cluster_name, group).get_cluster()
members = get_members(cluster, cluster_name, member_names, role, force, 'restart', False, group=group)
members = get_members(obj, cluster, cluster_name, member_names, role, force, 'restart', False, group=group)
if scheduled is None and not force:
next_hour = (datetime.datetime.now() + datetime.timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M')
scheduled = click.prompt('When should the restart take place (e.g. ' + next_hour + ') ',
@@ -1106,7 +1108,7 @@ def restart(cluster_name: str, group: Optional[int], member_names: List[str],
content['postgres_version'] = version
if scheduled_at:
if global_config.from_cluster(cluster).is_paused:
if get_global_config(cluster).is_paused:
raise PatroniCtlException("Can't schedule restart in the paused state")
content['schedule'] = scheduled_at.isoformat()
@@ -1138,7 +1140,9 @@ def restart(cluster_name: str, group: Optional[int], member_names: List[str],
@click.argument('member_names', nargs=-1)
@option_force
@click.option('--wait', help='Wait until reinitialization completes', is_flag=True)
def reinit(cluster_name: str, group: Optional[int], member_names: List[str], force: bool, wait: bool) -> None:
@click.pass_obj
def reinit(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
member_names: List[str], force: bool, wait: bool) -> None:
"""Process ``reinit`` command of ``patronictl`` utility.
Reinitialize cluster members based on given filters.
@@ -1146,14 +1150,15 @@ def reinit(cluster_name: str, group: Optional[int], member_names: List[str], for
.. note::
Only reinitialize replica members, not a leader.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should reinit members. Refer to the module note for more details.
:param member_names: name of the members that should be reinitialized.
:param force: perform the restart without asking for confirmations.
:param wait: wait for the operation to complete.
"""
cluster = get_dcs(cluster_name, group).get_cluster()
members = get_members(cluster, cluster_name, member_names, 'replica', force, 'reinitialize', group=group)
cluster = get_dcs(obj, cluster_name, group).get_cluster()
members = get_members(obj, cluster, cluster_name, member_names, 'replica', force, 'reinitialize', group=group)
wait_on_members: List[Member] = []
for member in members:
@@ -1184,8 +1189,8 @@ def reinit(cluster_name: str, group: Optional[int], member_names: List[str], for
wait_on_members.remove(member)
def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[int],
leader: Optional[str], candidate: Optional[str],
def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: str,
group: Optional[int], switchover_leader: Optional[str], candidate: Optional[str],
force: bool, scheduled: Optional[str] = None) -> None:
"""Perform a failover or a switchover operation in the cluster.
@@ -1195,11 +1200,12 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
.. note::
If not able to perform the operation through the REST API, write directly to the DCS as a fall back.
:param obj: Patroni configuration.
:param action: action to be taken -- ``failover`` or ``switchover``.
:param cluster_name: name of the Patroni cluster.
:param group: filter Citus group within we should perform a failover or switchover. If ``None``, user will be
prompted for filling it -- unless *force* is ``True``, in which case an exception is raised.
:param leader: name of the current leader member.
:param switchover_leader: name of the leader member passed as switchover option.
:param candidate: name of a standby member to be promoted. Nodes that are tagged with ``nofailover`` cannot be used.
:param force: perform the failover or switchover without asking for confirmations.
:param scheduled: timestamp when the switchover should be scheduled to occur. If ``now`` perform immediately.
@@ -1208,46 +1214,48 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
:class:`PatroniCtlException`: if:
* Patroni is running on a Citus cluster, but no *group* was specified; or
* a switchover was requested by the cluster has no leader; or
* *leader* does not match the current leader of the cluster; or
* *switchover_leader* does not match the current leader of the cluster; or
* cluster has no candidates available for the operation; or
* no *candidate* is given for a failover operation; or
* *leader* and *candidate* are the same; or
* current leader and *candidate* are the same; or
* *candidate* is tagged as nofailover; or
* *candidate* is not a member of the cluster; or
* trying to schedule a switchover in a cluster that is in maintenance mode; or
* user aborts the operation.
"""
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(obj, cluster_name, group)
cluster = dcs.get_cluster()
click.echo('Current cluster topology')
output_members(cluster, cluster_name, group=group)
output_members(obj, cluster, cluster_name, group=group)
if is_citus_cluster() and group is None:
if obj.get('citus') and group is None:
if force:
raise PatroniCtlException('For Citus clusters the --group must me specified')
else:
group = click.prompt('Citus group', type=int)
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(obj, cluster_name, group)
cluster = dcs.get_cluster()
config = global_config.from_cluster(cluster)
global_config = get_global_config(cluster)
cluster_leader = cluster.leader and cluster.leader.name
# leader has to be be defined for switchover only
if action == 'switchover':
if cluster.leader is None or not cluster.leader.name:
if not cluster_leader:
raise PatroniCtlException('This cluster has no leader')
if leader is None:
if switchover_leader is None:
if force:
leader = cluster.leader.name
switchover_leader = cluster_leader
else:
prompt = 'Standby Leader' if config.is_standby_cluster else 'Primary'
leader = click.prompt(prompt, type=str, default=(cluster.leader and cluster.leader.name))
prompt = 'Standby Leader' if global_config.is_standby_cluster else 'Primary'
switchover_leader = click.prompt(prompt, type=str, default=cluster_leader)
if cluster.leader.name != leader:
raise PatroniCtlException(f'Member {leader} is not the leader of cluster {cluster_name}')
if cluster_leader != switchover_leader:
raise PatroniCtlException(f'Member {switchover_leader} is not the leader of cluster {cluster_name}')
# excluding members with nofailover tag
candidate_names = [str(m.name) for m in cluster.members if m.name != leader and not m.nofailover]
candidate_names = [str(m.name) for m in cluster.members if m.name != cluster_leader and not m.nofailover]
# We sort the names for consistent output to the client
candidate_names.sort()
@@ -1260,16 +1268,16 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
if action == 'failover' and not candidate:
raise PatroniCtlException('Failover could be performed only to a specific candidate')
if candidate == leader:
raise PatroniCtlException(action.title() + ' target and source are the same.')
if candidate and candidate not in candidate_names:
if candidate == cluster_leader:
raise PatroniCtlException(
f'Member {candidate} is already the leader of cluster {cluster_name}')
raise PatroniCtlException(
f'Member {candidate} does not exist in cluster {cluster_name} or is tagged as nofailover')
if all((not force,
action == 'failover',
config.is_synchronous_mode,
global_config.is_synchronous_mode,
not cluster.sync.is_empty,
not cluster.sync.matches(candidate, True))):
if not click.confirm(f'Are you sure you want to failover to the asynchronous node {candidate}?'):
@@ -1286,13 +1294,13 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
scheduled_at = parse_scheduled(scheduled)
if scheduled_at:
if config.is_paused:
if global_config.is_paused:
raise PatroniCtlException("Can't schedule switchover in the paused state")
scheduled_at_str = scheduled_at.isoformat()
failover_value = {'candidate': candidate}
if action == 'switchover':
failover_value['leader'] = leader
failover_value['leader'] = switchover_leader
if scheduled_at_str:
failover_value['scheduled_at'] = scheduled_at_str
@@ -1300,7 +1308,7 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
# By now we have established that the leader exists and the candidate exists
if not force:
demote_msg = f', demoting current leader {cluster.leader.name}' if cluster.leader else ''
demote_msg = f', demoting current leader {cluster_leader}' if cluster_leader else ''
if scheduled_at_str:
# only switchover can be scheduled
if not click.confirm(f'Are you sure you want to schedule switchover of cluster '
@@ -1334,9 +1342,9 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
logging.exception(r)
logging.warning('Failing over to DCS')
click.echo('{0} Could not {1} using Patroni api, falling back to DCS'.format(timestamp(), action))
dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at)
dcs.manual_failover(switchover_leader, candidate, scheduled_at=scheduled_at)
output_members(cluster, cluster_name, group=group)
output_members(obj, cluster, cluster_name, group=group)
@ctl.command('failover', help='Failover to a replica')
@@ -1345,7 +1353,8 @@ def _do_failover_or_switchover(action: str, cluster_name: str, group: Optional[i
@click.option('--leader', '--primary', '--master', 'leader', help='The name of the current leader', default=None)
@click.option('--candidate', help='The name of the candidate', default=None)
@option_force
def failover(cluster_name: str, group: Optional[int],
@click.pass_obj
def failover(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
leader: Optional[str], candidate: Optional[str], force: bool) -> None:
"""Process ``failover`` command of ``patronictl`` utility.
@@ -1359,6 +1368,7 @@ def failover(cluster_name: str, group: Optional[int],
.. seealso::
Refer to :func:`_do_failover_or_switchover` for details.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter Citus group within we should perform a failover or switchover. If ``None``, user will be
prompted for filling it -- unless *force* is ``True``, in which case an exception is raised by
@@ -1373,7 +1383,7 @@ def failover(cluster_name: str, group: Optional[int],
click.echo(click.style(
'Supplying a leader name using this command is deprecated and will be removed in a future version of'
' Patroni, change your scripts to use `switchover` instead.\nExecuting switchover!', fg='red'))
_do_failover_or_switchover(action, cluster_name, group, leader, candidate, force)
_do_failover_or_switchover(obj, action, cluster_name, group, leader, candidate, force)
@ctl.command('switchover', help='Switchover to a replica')
@@ -1384,8 +1394,9 @@ def failover(cluster_name: str, group: Optional[int],
@click.option('--scheduled', help='Timestamp of a scheduled switchover in unambiguous format (e.g. ISO 8601)',
default=None)
@option_force
def switchover(cluster_name: str, group: Optional[int], leader: Optional[str],
candidate: Optional[str], force: bool, scheduled: Optional[str]) -> None:
@click.pass_obj
def switchover(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
leader: Optional[str], candidate: Optional[str], force: bool, scheduled: Optional[str]) -> None:
"""Process ``switchover`` command of ``patronictl`` utility.
Perform a switchover operation in the cluster.
@@ -1393,6 +1404,7 @@ def switchover(cluster_name: str, group: Optional[int], leader: Optional[str],
.. seealso::
Refer to :func:`_do_failover_or_switchover` for details.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter Citus group within we should perform a switchover. If ``None``, user will be prompted for
filling it -- unless *force* is ``True``, in which case an exception is raised by
@@ -1402,11 +1414,11 @@ def switchover(cluster_name: str, group: Optional[int], leader: Optional[str],
:param force: perform the switchover without asking for confirmations.
:param scheduled: timestamp when the switchover should be scheduled to occur. If ``now`` perform immediately.
"""
_do_failover_or_switchover('switchover', cluster_name, group, leader, candidate, force, scheduled)
_do_failover_or_switchover(obj, 'switchover', cluster_name, group, leader, candidate, force, scheduled)
def generate_topology(level: int, member: Dict[str, Any],
topology: Dict[str, List[Dict[str, Any]]]) -> Iterator[Dict[str, Any]]:
topology: Dict[Optional[str], List[Dict[str, Any]]]) -> Iterator[Dict[str, Any]]:
"""Recursively yield members with their names adjusted according to their *level* in the cluster topology.
.. note::
@@ -1469,7 +1481,7 @@ def topology_sort(members: List[Dict[str, Any]]) -> Iterator[Dict[str, Any]]:
:yields: *members* sorted by level in the topology, and with a new ``name`` value according to their level
in the topology.
"""
topology: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
topology: Dict[Optional[str], List[Dict[str, Any]]] = defaultdict(list)
leader = next((m for m in members if m['role'].endswith('leader')), {'name': None})
replicas = set(member['name'] for member in members if not member['role'].endswith('leader'))
for member in members:
@@ -1504,8 +1516,8 @@ def get_cluster_service_info(cluster: Dict[str, Any]) -> List[str]:
return service_info
def output_members(cluster: Cluster, name: str, extended: bool = False,
fmt: str = 'pretty', group: Optional[int] = None) -> None:
def output_members(obj: Dict[str, Any], cluster: Cluster, name: str,
extended: bool = False, fmt: str = 'pretty', group: Optional[int] = None) -> None:
"""Print information about the Patroni cluster and its members.
Information is printed to console through :func:`print_output`, and contains:
@@ -1530,6 +1542,7 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
The 3 extended columns are always included if *extended*, even if the member has no value for a given column.
If not *extended*, these columns may still be shown if any of the members has any information for them.
:param obj: Patroni configuration.
:param cluster: Patroni cluster.
:param name: name of the Patroni cluster.
:param extended: if extended information (pending restarts, scheduled restarts, node tags) should be printed, if
@@ -1547,7 +1560,8 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
clusters = {group or 0: cluster_as_json(cluster)}
if is_citus_cluster():
is_citus_cluster = obj.get('citus')
if is_citus_cluster:
columns.insert(1, 'Group')
if group is None:
clusters.update({g: cluster_as_json(c) for g, c in cluster.workers.items()})
@@ -1585,12 +1599,10 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
rows.append([member.get(n.lower().replace(' ', '_'), '') for n in columns])
if is_citus_cluster():
title = 'Citus cluster'
title = 'Citus cluster' if is_citus_cluster else 'Cluster'
title_details = f' ({initialize})'
if is_citus_cluster:
title_details = '' if group is None else f' (group: {group}, {initialize})'
else:
title = 'Cluster'
title_details = f' ({initialize})'
title = f' {title}: {name}{title_details} '
print_output(columns, rows, {'Group': 'r', 'Lag in MB': 'r', 'TL': 'r'}, fmt, title)
@@ -1601,7 +1613,7 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
for g, c in sorted(clusters.items()):
service_info = get_cluster_service_info(c)
if service_info:
if is_citus_cluster() and group is None:
if is_citus_cluster and group is None:
click.echo('Citus group: {0}'.format(g))
click.echo(' ' + '\n '.join(service_info))
@@ -1614,14 +1626,16 @@ def output_members(cluster: Cluster, name: str, extended: bool = False,
@option_format
@option_watch
@option_watchrefresh
def members(cluster_names: List[str], group: Optional[int], fmt: str,
watch: Optional[int], w: bool, extended: bool, ts: bool) -> None:
@click.pass_obj
def members(obj: Dict[str, Any], cluster_names: List[str], group: Optional[int],
fmt: str, watch: Optional[int], w: bool, extended: bool, ts: bool) -> None:
"""Process ``list`` command of ``patronictl`` utility.
Print information about the Patroni cluster through :func:`output_members`.
:param obj: Patroni configuration.
:param cluster_names: name of clusters that should be printed. If ``None`` consider only the cluster present in
``scope`` key of the configuration.
``scope`` key of *obj*.
:param group: filter which Citus group we should get members from. Refer to the module note for more details.
:param fmt: the output table printing format. See :func:`print_output` for available options.
:param watch: if given print output every *watch* seconds.
@@ -1630,10 +1644,9 @@ def members(cluster_names: List[str], group: Optional[int], fmt: str,
more details.
:param ts: if timestamp should be included in the output.
"""
config = _get_configuration()
if not cluster_names:
if 'scope' in config:
cluster_names = [config['scope']]
if 'scope' in obj:
cluster_names = [obj['scope']]
if not cluster_names:
return logging.warning('Listing members: No cluster names were provided')
@@ -1642,10 +1655,10 @@ def members(cluster_names: List[str], group: Optional[int], fmt: str,
click.echo(timestamp(0))
for cluster_name in cluster_names:
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(obj, cluster_name, group)
cluster = dcs.get_cluster()
output_members(cluster, cluster_name, extended, fmt, group)
output_members(obj, cluster, cluster_name, extended, fmt, group)
@ctl.command('topology', help='Prints ASCII topology for given cluster')
@@ -1687,12 +1700,14 @@ def timestamp(precision: int = 6) -> str:
@click.argument('target', type=click.Choice(['restart', 'switchover']))
@click.option('--role', '-r', help='Flush only members with this role', type=role_choice, default='any')
@option_force
def flush(cluster_name: str, group: Optional[int],
@click.pass_obj
def flush(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
member_names: List[str], force: bool, role: str, target: str) -> None:
"""Process ``flush`` command of ``patronictl`` utility.
Discard scheduled restart or switchover events.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should flush an event. Refer to the module note for more details.
:param member_names: name of the members which events should be flushed.
@@ -1700,11 +1715,11 @@ def flush(cluster_name: str, group: Optional[int],
:param role: role to filter members. See :func:`get_all_members` for available options.
:param target: the event that should be flushed -- ``restart`` or ``switchover``.
"""
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(obj, cluster_name, group)
cluster = dcs.get_cluster()
if target == 'restart':
for member in get_members(cluster, cluster_name, member_names, role, force, 'flush', group=group):
for member in get_members(obj, cluster, cluster_name, member_names, role, force, 'flush', group=group):
if member.data.get('scheduled_restart'):
r = request_patroni(member, 'delete', 'restart')
check_response(r, member.name, 'flush scheduled restart')
@@ -1740,7 +1755,7 @@ def wait_until_pause_is_applied(dcs: AbstractDCS, paused: bool, old_cluster: Clu
:param old_cluster: original cluster information before pause or unpause has been requested. Used to report which
nodes are still pending to have ``pause`` equal *paused* at a given point in time.
"""
config = global_config.from_cluster(old_cluster)
config = get_global_config(old_cluster)
click.echo("'{0}' request sent, waiting until it is recognized by all nodes".format(paused and 'pause' or 'resume'))
old = {m.name: m.version for m in old_cluster.members if m.api_url}
@@ -1762,9 +1777,10 @@ def wait_until_pause_is_applied(dcs: AbstractDCS, paused: bool, old_cluster: Clu
return click.echo('Success: cluster management is {0}'.format(paused and 'paused' or 'resumed'))
def toggle_pause(cluster_name: str, group: Optional[int], paused: bool, wait: bool) -> None:
def toggle_pause(config: Dict[str, Any], cluster_name: str, group: Optional[int], paused: bool, wait: bool) -> None:
"""Toggle the ``pause`` state in the cluster members.
:param config: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should toggle the pause state of. Refer to the module note for more
details.
@@ -1776,9 +1792,9 @@ def toggle_pause(cluster_name: str, group: Optional[int], paused: bool, wait: bo
* ``pause`` state is already *paused*; or
* cluster contains no accessible members.
"""
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(config, cluster_name, group)
cluster = dcs.get_cluster()
if global_config.from_cluster(cluster).is_paused == paused:
if get_global_config(cluster).is_paused == paused:
raise PatroniCtlException('Cluster is {0} paused'.format(paused and 'already' or 'not'))
for member in get_all_members_leader_first(cluster):
@@ -1805,33 +1821,37 @@ def toggle_pause(cluster_name: str, group: Optional[int], paused: bool, wait: bo
@ctl.command('pause', help='Disable auto failover')
@arg_cluster_name
@option_default_citus_group
@click.pass_obj
@click.option('--wait', help='Wait until pause is applied on all nodes', is_flag=True)
def pause(cluster_name: str, group: Optional[int], wait: bool) -> None:
def pause(obj: Dict[str, Any], cluster_name: str, group: Optional[int], wait: bool) -> None:
"""Process ``pause`` command of ``patronictl`` utility.
Put the cluster in maintenance mode.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should pause. Refer to the module note for more details.
:param wait: ``True`` if it should block until the operation is finished or ``false`` for returning immediately.
"""
return toggle_pause(cluster_name, group, True, wait)
return toggle_pause(obj, cluster_name, group, True, wait)
@ctl.command('resume', help='Resume auto failover')
@arg_cluster_name
@option_default_citus_group
@click.option('--wait', help='Wait until pause is cleared on all nodes', is_flag=True)
def resume(cluster_name: str, group: Optional[int], wait: bool) -> None:
@click.pass_obj
def resume(obj: Dict[str, Any], cluster_name: str, group: Optional[int], wait: bool) -> None:
"""Process ``unpause`` command of ``patronictl`` utility.
Put the cluster out of maintenance mode.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should unpause. Refer to the module note for more details.
:param wait: ``True`` if it should block until the operation is finished or ``false`` for returning immediately.
"""
return toggle_pause(cluster_name, group, False, wait)
return toggle_pause(obj, cluster_name, group, False, wait)
@contextmanager
@@ -2063,12 +2083,15 @@ def invoke_editor(before_editing: str, cluster_name: str) -> Tuple[str, Dict[str
@click.option('--replace', 'replace_filename', help='Apply configuration from file, replacing existing configuration.'
' Use - for stdin.')
@option_force
def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: bool, kvpairs: List[str],
pgkvpairs: List[str], apply_filename: Optional[str], replace_filename: Optional[str]) -> None:
@click.pass_obj
def edit_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int],
force: bool, quiet: bool, kvpairs: List[str], pgkvpairs: List[str],
apply_filename: Optional[str], replace_filename: Optional[str]) -> None:
"""Process ``edit-config`` command of ``patronictl`` utility.
Update or replace Patroni configuration in the DCS.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group configuration we should edit. Refer to the module note for more details.
:param force: if ``True`` apply config changes without asking for confirmations.
@@ -2085,7 +2108,7 @@ def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: boo
* Configuration is absent from DCS; or
* Detected a concurrent modification of the configuration in the DCS.
"""
dcs = get_dcs(cluster_name, group)
dcs = get_dcs(obj, cluster_name, group)
cluster = dcs.get_cluster()
if not cluster.config:
@@ -2123,7 +2146,7 @@ def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: boo
return
if force or click.confirm('Apply these changes?'):
if not dcs.set_config_value(json.dumps(changed_data, separators=(',', ':')), cluster.config.version):
if not dcs.set_config_value(json.dumps(changed_data), cluster.config.version):
raise PatroniCtlException("Config modification aborted due to concurrent changes")
click.echo("Configuration changed")
@@ -2131,15 +2154,17 @@ def edit_config(cluster_name: str, group: Optional[int], force: bool, quiet: boo
@ctl.command('show-config', help="Show cluster configuration")
@arg_cluster_name
@option_default_citus_group
def show_config(cluster_name: str, group: Optional[int]) -> None:
@click.pass_obj
def show_config(obj: Dict[str, Any], cluster_name: str, group: Optional[int]) -> None:
"""Process ``show-config`` command of ``patronictl`` utility.
Show Patroni configuration stored in the DCS.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group configuration we should show. Refer to the module note for more details.
"""
cluster = get_dcs(cluster_name, group).get_cluster()
cluster = get_dcs(obj, cluster_name, group).get_cluster()
if cluster.config:
click.echo(format_config_for_editing(cluster.config.data))
@@ -2148,7 +2173,8 @@ def show_config(cluster_name: str, group: Optional[int]) -> None:
@click.argument('cluster_name', required=False)
@click.argument('member_names', nargs=-1)
@option_citus_group
def version(cluster_name: str, group: Optional[int], member_names: List[str]) -> None:
@click.pass_obj
def version(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member_names: List[str]) -> None:
"""Process ``version`` command of ``patronictl`` utility.
Show version of:
@@ -2156,6 +2182,7 @@ def version(cluster_name: str, group: Optional[int], member_names: List[str]) ->
* ``patroni`` on all members of the cluster;
* ``PostgreSQL`` on all members of the cluster.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should get members from. Refer to the module note for more details.
:param member_names: filter which members we should get version information from.
@@ -2166,8 +2193,8 @@ def version(cluster_name: str, group: Optional[int], member_names: List[str]) ->
return
click.echo("")
cluster = get_dcs(cluster_name, group).get_cluster()
for m in get_all_members(cluster, group, 'any'):
cluster = get_dcs(obj, cluster_name, group).get_cluster()
for m in get_all_members(obj, cluster, group, 'any'):
if m.api_url:
if not member_names or m.name in member_names:
try:
@@ -2185,7 +2212,8 @@ def version(cluster_name: str, group: Optional[int], member_names: List[str]) ->
@arg_cluster_name
@option_default_citus_group
@option_format
def history(cluster_name: str, group: Optional[int], fmt: str) -> None:
@click.pass_obj
def history(obj: Dict[str, Any], cluster_name: str, group: Optional[int], fmt: str) -> None:
"""Process ``history`` command of ``patronictl`` utility.
Show the history of failover/switchover events in the cluster.
@@ -2197,11 +2225,12 @@ def history(cluster_name: str, group: Optional[int], fmt: str) -> None:
* ``Timestamp``: timestamp when the event occurred;
* ``New Leader``: the Postgres node that was promoted during the event.
:param obj: Patroni configuration.
:param cluster_name: name of the Patroni cluster.
:param group: filter which Citus group we should get events from. Refer to the module note for more details.
:param fmt: the output table printing format. See :func:`print_output` for available options.
"""
cluster = get_dcs(cluster_name, group).get_cluster()
cluster = get_dcs(obj, cluster_name, group).get_cluster()
cluster_history = cluster.history.lines if cluster.history else []
history: List[List[Any]] = list(map(list, cluster_history))
table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader']
+172 -86
View File
@@ -1,22 +1,26 @@
"""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
@@ -24,7 +28,6 @@ from ..utils import parse_int
if TYPE_CHECKING: # pragma: no cover
from ..config import Config
from ..postgresql import Postgresql
SLOT_ADVANCE_AVAILABLE_VERSION = 110000
CITUS_COORDINATOR_GROUP_ID = 0
@@ -84,9 +87,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``.
"""
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(
@@ -100,16 +122,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.
"""
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 classes returned by :func:`iter_dcs_classes` attempt to dynamically
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
@@ -135,9 +185,9 @@ def get_dcs(config: Union['Config', Dict[str, Any]]) -> 'AbstractDCS':
config[name].update(config['citus'])
return dcs_class(config[name])
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]
@@ -540,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.
@@ -558,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.
@@ -928,7 +996,7 @@ class Cluster(NamedTuple('Cluster',
@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
ret: Dict[str, Union[Dict[str, Any], Any]] = deepcopy(self.config.permanent_slots if self.config else {})
members: Dict[str, int] = {slot_name_from_member_name(m.name): m.lsn or 0 for m in self.members}
slots: Dict[str, int] = {k: parse_int(v) or 0 for k, v in (self.slots or {}).items()}
@@ -957,29 +1025,36 @@ class Cluster(NamedTuple('Cluster',
"""Dictionary of permanent ``logical`` replication slots."""
return {name: value for name, value in self.__permanent_slots.items() if self.is_logical_slot(value)}
def get_replication_slots(self, postgresql: 'Postgresql', member: Tags, *,
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=is_standby_cluster,
role=role, nofailover=nofailover,
major_version=major_version)
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. "
@@ -987,7 +1062,7 @@ class Cluster(NamedTuple('Cluster',
return slots
def _merge_permanent_slots(self, slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], name: str,
def _merge_permanent_slots(self, slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], my_name: str,
major_version: int) -> List[str]:
"""Merge replication *slots* for members with *permanent_slots*.
@@ -997,7 +1072,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.
@@ -1005,9 +1080,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
@@ -1018,24 +1093,25 @@ 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:
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, major_version: int) -> Dict[str, Any]:
"""Get configured permanent replication slots.
.. note::
@@ -1047,23 +1123,25 @@ class Cluster(NamedTuple('Cluster',
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.
:param major_version: postgresql major version.
: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:
if is_standby_cluster:
return self.__permanent_physical_slots \
if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {}
if major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {}
return self.__permanent_slots if postgresql.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION\
return self.__permanent_slots if major_version >= SLOT_ADVANCE_AVAILABLE_VERSION\
or role in ('master', 'primary') else self.__permanent_logical_slots
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,25 +1153,25 @@ class Cluster(NamedTuple('Cluster',
* 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
members = filter(lambda m: m.name != name, self.members)
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):
@@ -1106,76 +1184,84 @@ 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.
def has_permanent_slots(self, my_name: str, *, is_standby_cluster: bool = False, nofailover: bool = False,
major_version: int = SLOT_ADVANCE_AVAILABLE_VERSION) -> bool:
"""Check if the given member node has permanent replication slots configured.
:param postgresql: reference to :class:`Postgresql` object.
:param 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 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 nofailover: ``True`` if this node is tagged to not be a failover candidate.
:param major_version: postgresql major version.
:returns: ``True`` if there are permanent replication slots configured, otherwise ``False``.
"""
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)
members_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=is_standby_cluster,
role=role, nofailover=nofailover,
major_version=major_version)
slots = deepcopy(members_slots)
self._merge_permanent_slots(slots, permanent_slots, postgresql.name, postgresql.major_version)
self._merge_permanent_slots(slots, permanent_slots, my_name, major_version)
return len(slots) > len(members_slots) or any(self.is_physical_slot(v) for v in permanent_slots.values())
def filter_permanent_slots(self, postgresql: 'Postgresql', slots: Dict[str, int]) -> Dict[str, int]:
def filter_permanent_slots(self, slots: Dict[str, int], is_standby_cluster: bool,
major_version: int) -> Dict[str, int]:
"""Filter out all non-permanent slots from provided *slots* dict.
:param postgresql: reference to :class:`Postgresql` object.
:param slots: slot names with LSN values.
:param slots: slot names with LSN values
:param is_standby_cluster: ``True`` if it is known that this is a standby cluster. We pass the value from
the outside because we want to protect from the ``/config`` key removal.
:param major_version: postgresql major version.
:returns: a :class:`dict` object that contains only slots that are known to be permanent.
"""
if postgresql.major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
if major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
return {} # for legacy PostgreSQL we don't support permanent slots on standby nodes
permanent_slots: Dict[str, Any] = self._get_permanent_slots(postgresql, RemoteMember('', {}), 'replica')
permanent_slots: Dict[str, Any] = self._get_permanent_slots(is_standby_cluster=is_standby_cluster,
role='replica',
nofailover=False,
major_version=major_version)
members_slots = {slot_name_from_member_name(m.name) for m in self.members}
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) -> 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.
:returns: ``True`` if any detected replications slots are ``logical``, otherwise ``False``.
"""
slots = self.get_replication_slots(postgresql, member, role='replica').values()
slots = self.get_replication_slots(my_name, 'replica', nofailover, SLOT_ADVANCE_AVAILABLE_VERSION).values()
return any(v for v in slots if v.get("type") == "logical")
def should_enforce_hot_standby_feedback(self, postgresql: 'Postgresql', member: Tags) -> bool:
def should_enforce_hot_standby_feedback(self, my_name: str, nofailover: bool) -> bool:
"""Determine whether ``hot_standby_feedback`` should be enabled for the given member.
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.
:returns: ``True`` if this node or any member replicating from this node has
permanent logical slots, otherwise ``False``.
"""
if self._has_permanent_logical_slots(postgresql, member):
if self._has_permanent_logical_slots(my_name, nofailover):
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) for m in members)
return False
def get_slot_name_on_primary(self, name: str, tags: Tags) -> 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
@@ -1183,14 +1269,14 @@ class Cluster(NamedTuple('Cluster',
In case of cascading replication we have to check not our physical slot, but slot of the replica that
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.
"""
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:
+1 -2
View File
@@ -115,8 +115,7 @@ 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.__last_member_data: Optional[Dict[str, Any]] = None
-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)
-227
View File
@@ -1,227 +0,0 @@
"""Implements *global_config* facilities.
The :class:`GlobalConfig` object is instantiated on import and replaces
``patroni.global_config`` module in :data:`sys.modules`, what allows to use
its properties and methods like they were module variables and functions.
"""
import sys
import types
from copy import deepcopy
from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING
from .utils import parse_bool, parse_int
if TYPE_CHECKING: # pragma: no cover
from .dcs import Cluster
def __getattr__(mod: types.ModuleType, name: str) -> Any:
"""This function exists just to make pyright happy.
Without it pyright complains about access to unknown members of global_config module.
"""
return getattr(sys.modules[__name__], name) # pragma: no cover
class GlobalConfig(types.ModuleType):
"""A class that wraps global configuration and provides convenient methods to access/check values."""
__file__ = __file__ # just to make unittest and pytest happy
def __init__(self) -> None:
"""Initialize :class:`GlobalConfig` object."""
super().__init__(__name__)
self.__config = {}
@staticmethod
def _cluster_has_valid_config(cluster: Optional['Cluster']) -> bool:
"""Check if provided *cluster* object has a valid global configuration.
:param cluster: the currently known cluster state from DCS.
:returns: ``True`` if provided *cluster* object has a valid global configuration, otherwise ``False``.
"""
return bool(cluster and cluster.config and cluster.config.modify_version)
def update(self, cluster: Optional['Cluster']) -> None:
"""Update with the new global configuration from the :class:`Cluster` object view.
.. note::
Global configuration is updated only when configuration in the *cluster* view is valid.
Update happens in-place and is executed only from the main heartbeat thread.
:param cluster: the currently known cluster state from DCS.
"""
# 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]
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 {}).get('use_slots', True)))
@property
def permanent_slots(self) -> Dict[str, Any]:
"""Dictionary of permanent slots information from the global configuration."""
return deepcopy(self.get('permanent_replication_slots')
or self.get('permanent_slots')
or self.get('slots')
or {})
sys.modules[__name__] = GlobalConfig()
+32 -27
View File
@@ -10,7 +10,7 @@ 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
@@ -156,6 +156,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()
@@ -187,20 +188,20 @@ class Ha(object):
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."""
@@ -294,8 +295,9 @@ class Ha(object):
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})
{**self.state_handler.slots(), slot_name_from_member_name(self.state_handler.name): last_lsn},
self.is_standby_cluster(),
self.state_handler.major_version)
except Exception:
logger.exception('Exception when called state_handler.last_operation()')
if TYPE_CHECKING: # pragma: no cover
@@ -448,7 +450,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 == ""
@@ -523,7 +525,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.
@@ -620,7 +622,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
@@ -682,11 +684,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.
@@ -730,7 +732,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!")
@@ -803,7 +805,7 @@ class Ha(object):
cluster_history_dict: Dict[int, List[Any]] = {line[0]: list(line) for line in cluster_history}
history: List[List[Any]] = list(map(list, self.state_handler.get_history(primary_timeline)))
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], [])
@@ -861,7 +863,7 @@ 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)
@@ -972,7 +974,7 @@ 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 _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."""
@@ -1539,7 +1541,7 @@ 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:
@@ -1603,7 +1605,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)
@@ -1688,7 +1690,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()
@@ -1711,7 +1713,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:
@@ -1744,8 +1746,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
@@ -1765,10 +1767,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()
@@ -1901,7 +1903,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
@@ -1957,7 +1959,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
@@ -2041,7 +2046,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})
+29 -12
View File
@@ -24,17 +24,17 @@ from .misc import parse_history, parse_lsn, postgres_major_version_to_int
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
from ..dcs import Cluster, Leader, Member, SLOT_ADVANCE_AVAILABLE_VERSION
from ..exceptions import PostgresConnectionException
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
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__)
@@ -73,6 +73,7 @@ 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')
@@ -218,7 +219,7 @@ 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:
@@ -429,30 +430,46 @@ 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.set_enforce_hot_standby_feedback(False)
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(not self._global_config.is_standby_cluster and self.can_advance_slots
and cluster.should_enforce_hot_standby_feedback(self.name,
nofailover))
self._has_permanent_slots = cluster.has_permanent_slots(
my_name=self.name,
is_standby_cluster=self._global_config.is_standby_cluster,
nofailover=nofailover,
major_version=self.major_version)
def _cluster_info_state_get(self, name: str) -> Optional[Any]:
if not self._cluster_info_state:
+7 -2
View File
@@ -7,7 +7,7 @@ from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
from ..dcs import CITUS_COORDINATOR_GROUP_ID, Cluster
from ..psycopg import connect, quote_ident
from ..psycopg import connect, quote_ident, ProgrammingError
if TYPE_CHECKING: # pragma: no cover
from . import Postgresql
@@ -364,6 +364,11 @@ class CitusHandler(Thread):
with conn.cursor() as cur:
cur.execute('CREATE DATABASE {0}'.format(
quote_ident(self._config['database'], conn)).encode('utf-8'))
except ProgrammingError as exc:
if exc.diag.sqlstate == '42P04': # DuplicateDatabase
logger.debug('Exception when creating database: %r', exc)
else:
raise exc
finally:
conn.close()
@@ -371,7 +376,7 @@ class CitusHandler(Thread):
conn = connect(**conn_kwargs)
try:
with conn.cursor() as cur:
cur.execute('CREATE EXTENSION citus')
cur.execute('CREATE EXTENSION IF NOT EXISTS citus')
superuser = self._postgresql.config.superuser
params = {k: superuser[k] for k in ('password', 'sslcert', 'sslkey') if k in superuser}
+22 -11
View File
@@ -12,7 +12,6 @@ from types import TracebackType
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
from ..dcs import Leader, Member, RemoteMember, slot_name_from_member_name
from ..exceptions import PatroniFatalException, PostgresConnectionException
@@ -337,12 +336,24 @@ class ConfigHandler(object):
def load_current_server_parameters(self) -> None:
"""Read GUC's values from ``pg_settings`` when Patroni is joining the the postgres that is already running."""
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator] \
+ [name.lower() for name in self._RECOVERY_PARAMETERS]
self._server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator]
keep_values = {k: self._server_parameters[k] for k in exclude}
server_parameters = CaseInsensitiveDict({r[0]: r[1] for r in self._postgresql.query(
"SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings"
" WHERE (source IN ('command line', 'environment variable') OR sourcefile = %s)"
" AND pg_catalog.lower(name) != ALL(%s)", self._postgresql_conf, exclude)})
recovery_params = CaseInsensitiveDict({k: server_parameters.pop(k) for k in self._RECOVERY_PARAMETERS
if k in server_parameters})
# We also want to load current settings of recovery parameters, including primary_conninfo
# and primary_slot_name, otherwise patronictl restart will update postgresql.conf
# and remove them, what in the worst case will cause another restart.
# We are doing it only for PostgresSQL v12 onwards, because older version still have recovery.conf
if not self._postgresql.is_primary() and self._postgresql.major_version >= 120000:
# primary_conninfo is expected to be a dict, therefore we need to parse it
recovery_params['primary_conninfo'] = parse_dsn(recovery_params.pop('primary_conninfo', '')) or {}
self._recovery_params = recovery_params
self._server_parameters = CaseInsensitiveDict({**server_parameters, **keep_values})
def setup_server_parameters(self) -> None:
self._server_parameters = self.get_server_parameters(self._config)
@@ -596,7 +607,7 @@ 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)
@@ -931,10 +942,10 @@ 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:
if not self._postgresql.global_config or self._postgresql.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 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:
@@ -1065,13 +1076,14 @@ class ConfigHandler(object):
def reload_config(self, config: Dict[str, Any], sighup: bool = False) -> None:
self._superuser = config['authentication'].get('superuser', {})
server_parameters = self.get_server_parameters(config)
params_skip_changes = CaseInsensitiveSet((*self._RECOVERY_PARAMETERS, 'hot_standby', 'wal_log_hints'))
conf_changed = hba_changed = ident_changed = local_connection_address_changed = pending_restart = False
if self._postgresql.state == 'running':
changes = CaseInsensitiveDict({p: v for p, v in server_parameters.items()
if p.lower() not in self._RECOVERY_PARAMETERS})
if p not in params_skip_changes})
changes.update({p: None for p in self._server_parameters.keys()
if not (p in changes or p.lower() in self._RECOVERY_PARAMETERS)})
if not (p in changes or p in params_skip_changes)})
if changes:
undef = []
if 'wal_buffers' in changes: # we need to calculate the default value of wal_buffers
@@ -1157,7 +1169,7 @@ class ConfigHandler(object):
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
[n.lower() for n in params_skip_changes])[0][0] > 0
self._postgresql.set_pending_restart(pending_restart)
except Exception as e:
logger.warning('Exception %r when running query', e)
@@ -1232,7 +1244,6 @@ class ConfigHandler(object):
if disable_hot_standby:
effective_configuration['hot_standby'] = 'off'
self._postgresql.set_pending_restart(True)
return effective_configuration
+20 -17
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
@@ -295,7 +293,7 @@ class SlotsHandler:
"""
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)
@@ -321,7 +319,7 @@ class SlotsHandler:
' FULL OUTER JOIN dropped ON true'), name)
return (rows[0][0], rows[0][1]) if rows else (False, False)
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any]) -> None:
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any], paused: bool) -> None:
"""Compare required slots and configured as permanent slots with those found, dropping extraneous ones.
.. note::
@@ -332,10 +330,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)
@@ -493,7 +492,8 @@ 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.
@@ -503,18 +503,22 @@ class SlotsHandler:
them on replica nodes by copying slot files from the primary.
: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 +526,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 +552,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 +561,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 +645,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()
-468
View File
@@ -1,468 +0,0 @@
#!/usr/bin/env python
"""Restore a Barman backup to the local node through ``pg-backup-api``.
This script 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 script. ``--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 script.
"""
from argparse import ArgumentParser
from enum import IntEnum
import json
import logging
import sys
import time
from typing import Any, Callable, Optional, Tuple, Type, Union
from urllib.parse import urljoin
from urllib3 import PoolManager
from urllib3.exceptions import MaxRetryError
from urllib3.response import HTTPResponse
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 API_NOT_OK: ``pg-backup-api`` status is not ``OK``.
:cvar HTTP_REQUEST_ERROR: an error has occurred during a request to the
``pg-backup-api``.
:cvar HTTP_RESPONSE_MALFORMED: ``pg-backup-api`` returned a bogus response.
"""
RECOVERY_DONE = 0
RECOVERY_FAILED = 1
API_NOT_OK = 2
HTTP_REQUEST_ERROR = 3
HTTP_RESPONSE_MALFORMED = 4
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 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
class BarmanRecover:
"""Facilities for performing a remote ``barman recover`` operation.
You should instantiate this class, which will take care of configuring the
operation accordingly. When you want to start the operation, you should
call :meth:`restore_backup`. At any point of interaction with this class,
you may face a :func:`sys.exit` call. Refer to :class:`ExitCode` for a view
on the possible exit codes.
: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 barman_server: name of the Barman server which backup is to be
restored.
:ivar backup_id: ID of the backup from the Barman server.
:ivar ssh_command: SSH command to connect from the Barman host to the
local host.
:ivar data_directory: path to the Postgres data directory where to
restore the backup at.
:ivar loop_wait: how long to wait before checking again the status of the
recovery process. Higher values are useful for backups that are
expected to take long to restore.
:ivar retry_wait: how long 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, barman_server: str, backup_id: str,
ssh_command: str, data_directory: str, loop_wait: int,
retry_wait: int, max_retries: int,
cert_file: Optional[str] = None,
key_file: Optional[str] = None) -> None:
"""Create a new instance of :class:`BarmanRecover`.
Make sure the ``pg-backup-api`` is reachable and running fine.
:param api_url: base URL to reach 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
local host.
:param data_directory: path to the Postgres data directory where to
restore the backup at.
:param loop_wait: how long to wait before checking again the status of
the recovery process. Higher values are useful for backups that are
expected to take long to restore.
:param retry_wait: how long 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.
: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.
"""
self.api_url = api_url
self.cert_file = cert_file
self.key_file = key_file
self.barman_server = barman_server
self.backup_id = backup_id
self.ssh_command = ssh_command
self.data_directory = data_directory
self.loop_wait = loop_wait
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*.
.. note::
If a :exc:`MaxRetryError` is faced while performing the request,
then exit with :attr:`ExitCode.HTTP_REQUEST_ERROR`
:param url_path: URL to perform the ``GET`` request against.
:returns: the deserialized response body.
"""
response = None
try:
response = self.http.request("GET", self._build_full_url(url_path))
except MaxRetryError as exc:
logging.critical("An error occurred while performing an HTTP GET "
"request: %r", exc)
sys.exit(ExitCode.HTTP_REQUEST_ERROR)
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.
.. note::
If a :exc:`MaxRetryError` is faced while performing the request,
then exit with :attr:`ExitCode.HTTP_REQUEST_ERROR`
: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.
"""
body = self._serialize_request(body)
response = None
try:
response = self.http.request("POST",
self._build_full_url(url_path),
body=body,
headers={
"Content-Type": "application/json"
})
except MaxRetryError as exc:
logging.critical("An error occurred while performing an HTTP POST "
"request: %r", exc)
sys.exit(ExitCode.HTTP_REQUEST_ERROR)
return self._deserialize_response(response)
def _ensure_api_ok(self) -> None:
"""Ensure ``pg-backup-api`` is reachable and ``OK``.
.. note::
If ``pg-backup-api`` status is not ``OK``, then exit with
:attr:`ExitCode.API_NOT_OK`.
"""
response = self._get_request("status")
if response != "OK":
logging.critical("pg-backup-api is not working: %s", response)
sys.exit(ExitCode.API_NOT_OK)
@retry(KeyError)
def _create_recovery_operation(self) -> str:
"""Create a recovery operation on the ``pg-backup-api``.
:returns: the ID of the recovery operation that has been created.
"""
response = self._post_request(
f"servers/{self.barman_server}/operations",
{
"type": "recovery",
"backup_id": self.backup_id,
"remote_ssh_command": self.ssh_command,
"destination_directory": self.data_directory,
},
)
return response["operation_id"]
@retry(KeyError)
def _get_recovery_operation_status(self, operation_id: str) -> str:
"""Get status of the recovery operation *operation_id*.
:param operation_id: ID of the recovery operation to be checked.
:returns: the status of the recovery operation.
"""
response = self._get_request(
f"servers/{self.barman_server}/operations/{operation_id}",
)
return response["status"]
def restore_backup(self) -> bool:
"""Restore the configured Barman backup through ``pg-backup-api``.
.. note::
If recovery API request returns a malformed response, then exit with
:attr:`ExitCode.HTTP_RESPONSE_MALFORMED`.
:returns: ``True`` if it was successfully recovered, ``False``
otherwise.
"""
operation_id = None
try:
operation_id = self._create_recovery_operation()
except RetriesExceeded:
logging.critical("Maximum number of retries exceeded, exiting.")
sys.exit(ExitCode.HTTP_RESPONSE_MALFORMED)
logging.info("Created the recovery operation with ID %s", operation_id)
status = None
while True:
try:
status = self._get_recovery_operation_status(operation_id)
except RetriesExceeded:
logging.critical("Maximum number of retries exceeded, "
"exiting.")
sys.exit(ExitCode.HTTP_RESPONSE_MALFORMED)
if status != "IN_PROGRESS":
break
logging.info("Recovery operation %s is still in progress",
operation_id)
time.sleep(self.loop_wait)
return status == "DONE"
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")
def main() -> None:
"""Entry point of this script.
Parse the command-line arguments and recover a Barman backup through
``pg-backup-api`` to the local host.
"""
parser = ArgumentParser(
epilog=(
"Wrapper script for ``pg-backup-api``. Communicate with the API "
"running at ``--api-url`` to restore a ``--backup-id`` Barman "
"backup of the server ``--barman-server``."
),
)
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(
"--barman-server",
type=str,
required=True,
help="Name of the Barman server from which to restore the backup.",
dest="barman_server",
)
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",
)
parser.add_argument(
"--ssh-command",
type=str,
required=True,
help="Value to be passed as ``--remote-ssh-command`` to "
"``barman recover``.",
dest="ssh_command",
)
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",
)
parser.add_argument(
"--log-file",
type=str,
required=False,
help="File where to log messages produced by this script, if any.",
dest="log_file",
)
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",
)
parser.add_argument(
"--retry-wait",
type=int,
required=False,
default=2,
help="How long 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",
)
args, _ = parser.parse_known_args()
set_up_logging(args.log_file)
barman_recover = BarmanRecover(args.api_url, args.barman_server,
args.backup_id, args.ssh_command,
args.data_directory, args.loop_wait,
args.retry_wait, args.max_retries,
args.cert_file, args.key_file)
successful = barman_recover.restore_backup()
if successful:
logging.info("Recovery operation finished successfully.")
sys.exit(ExitCode.RECOVERY_DONE)
else:
logging.critical("Recovery operation failed.")
sys.exit(ExitCode.RECOVERY_FAILED)
if __name__ == "__main__":
main()
+7 -3
View File
@@ -22,14 +22,18 @@ class Tags(abc.ABC):
A custom tag is any tag added to the configuration ``tags`` section that is not one of ``clonefrom``,
``nofailover``, ``noloadbalance`` or ``nosync``.
For the Patroni predefined tags, the returning object will only contain them if they are enabled as they
all are boolean values that default to disabled.
For most of the Patroni predefined tags, the returning object will only contain them if they are enabled as
they all are boolean values that default to disabled.
However ``nofailover`` tag is always returned if ``failover_priority`` tag is defined. In this case, we need
both values to see if they are contradictory and the ``nofailover`` value should be used.
:returns: a dictionary of tags set for this node. The key is the tag name, and the value is the corresponding
tag value.
"""
return {tag: value for tag, value in tags.items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
if any((tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync'),
value,
tag == 'nofailover' and 'failover_priority' in tags))}
@property
@abc.abstractmethod
+9 -6
View File
@@ -33,6 +33,7 @@ from .version import __version__
if TYPE_CHECKING: # pragma: no cover
from .dcs import Cluster
from .config import GlobalConfig
tzutc = tz.tzutc()
@@ -759,10 +760,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*.
@@ -791,16 +794,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:
@@ -830,7 +833,7 @@ 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()}
+1 -2
View File
@@ -1050,8 +1050,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": {},
+1 -1
View File
@@ -2,4 +2,4 @@
:var __version__: the current Patroni version.
"""
__version__ = '3.2.1'
__version__ = '3.2.2'
+1 -2
View File
@@ -54,8 +54,7 @@ CONSOLE_SCRIPTS = ['patroni = patroni.__main__:main',
'patronictl = patroni.ctl:ctl',
'patroni_raft_controller = patroni.raft_controller:main',
"patroni_wale_restore = patroni.scripts.wale_restore:main",
"patroni_aws = patroni.scripts.aws:main",
"patroni_barman_recover = patroni.scripts.barman_recover:main"]
"patroni_aws = patroni.scripts.aws:main"]
class _Command(Command):
+4 -4
View File
@@ -54,10 +54,10 @@ GET_PG_SETTINGS_RESULT = [
('zero_damaged_pages', 'off', None, 'bool', 'superuser'),
('stats_temp_directory', '/tmp', None, 'string', 'sighup'),
('track_commit_timestamp', 'off', None, 'bool', 'postmaster'),
('wal_log_hints', 'on', None, 'bool', 'superuser'),
('hot_standby', 'on', None, 'bool', 'superuser'),
('max_replication_slots', '5', None, 'integer', 'superuser'),
('wal_level', 'logical', None, 'enum', 'superuser'),
('wal_log_hints', 'on', None, 'bool', 'postmaster'),
('hot_standby', 'on', None, 'bool', 'postmaster'),
('max_replication_slots', '5', None, 'integer', 'postmaster'),
('wal_level', 'logical', None, 'enum', 'postmaster'),
]
+17 -12
View File
@@ -8,8 +8,8 @@ from io import BytesIO as IO
from mock import Mock, PropertyMock, patch
from socketserver import ThreadingMixIn
from patroni import global_config
from patroni.api import RestApiHandler, RestApiServer
from patroni.config import GlobalConfig
from patroni.dcs import ClusterConfig, Member
from patroni.exceptions import PostgresConnectionException
from patroni.ha import _MemberStatus
@@ -148,9 +148,16 @@ class MockLogger(object):
records_lost = 1
class MockConfig(object):
def get_global_config(self, _):
return GlobalConfig({})
class MockPatroni(object):
ha = MockHa()
config = MockConfig()
postgresql = ha.state_handler
dcs = Mock()
logger = MockLogger()
@@ -204,7 +211,7 @@ class TestRestApiHandler(unittest.TestCase):
def test_do_GET(self):
MockPatroni.dcs.cluster.last_lsn = 20
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
with patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True)):
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB')
@@ -227,7 +234,7 @@ class TestRestApiHandler(unittest.TestCase):
with patch.object(MockHa, 'is_leader', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, 'GET /read-only-sync')
with patch.object(global_config.__class__, 'is_standby_cluster', Mock(return_value=True)):
with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
MockPatroni.dcs.cluster = None
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'primary'})):
@@ -237,8 +244,8 @@ class TestRestApiHandler(unittest.TestCase):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /primary'))
with patch.object(RestApiServer, 'query', Mock(return_value=[('', 1, '', '', '', '', False, None, None, '')])):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
with patch.object(global_config.__class__, 'is_standby_cluster', Mock(return_value=True)), \
patch.object(global_config.__class__, 'is_paused', Mock(return_value=True)):
with patch.object(GlobalConfig, 'is_standby_cluster', Mock(return_value=True)), \
patch.object(GlobalConfig, 'is_paused', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader')
# test tags
@@ -468,7 +475,7 @@ class TestRestApiHandler(unittest.TestCase):
request = make_request(role='primary', postgres_version='9.5.2')
MockRestApiServer(RestApiHandler, request)
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
with patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
MockRestApiServer(RestApiHandler, make_request(schedule='2016-08-42 12:45TZ+1', role='primary'))
# Valid timeout
MockRestApiServer(RestApiHandler, make_request(timeout='60s'))
@@ -530,7 +537,7 @@ class TestRestApiHandler(unittest.TestCase):
# Switchover in pause mode
with patch.object(RestApiHandler, 'write_response') as response_mock, \
patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
MockRestApiServer(RestApiHandler, request)
response_mock.assert_called_with(
400, 'Switchover is possible only to a specific candidate in a paused state')
@@ -539,8 +546,7 @@ class TestRestApiHandler(unittest.TestCase):
for is_synchronous_mode, response in (
(True, 'switchover is not possible: can not find sync_standby'),
(False, 'switchover is not possible: cluster does not have members except leader')):
with patch.object(global_config.__class__, 'is_synchronous_mode',
PropertyMock(return_value=is_synchronous_mode)), \
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)), \
patch.object(RestApiHandler, 'write_response') as response_mock:
MockRestApiServer(RestApiHandler, request)
response_mock.assert_called_with(412, response)
@@ -565,8 +571,7 @@ class TestRestApiHandler(unittest.TestCase):
cluster.sync.matches.return_value = False
for is_synchronous_mode, response in (
(True, 'candidate name does not match with sync_standby'), (False, 'candidate does not exists')):
with patch.object(global_config.__class__, 'is_synchronous_mode',
PropertyMock(return_value=is_synchronous_mode)), \
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)), \
patch.object(RestApiHandler, 'write_response') as response_mock:
MockRestApiServer(RestApiHandler, request)
response_mock.assert_called_with(412, response)
@@ -627,7 +632,7 @@ class TestRestApiHandler(unittest.TestCase):
# Schedule in paused mode
with patch.object(RestApiHandler, 'write_response') as response_mock, \
patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
dcs.manual_failover.return_value = False
MockRestApiServer(RestApiHandler, request)
response_mock.assert_called_with(400, "Can't schedule switchover in the paused state")
-366
View File
@@ -1,366 +0,0 @@
import logging
import mock
from mock import MagicMock, Mock, patch
import unittest
from urllib3.exceptions import MaxRetryError
from patroni.scripts.barman_recover import BarmanRecover, ExitCode, RetriesExceeded, main, set_up_logging
API_URL = "http://localhost:7480"
BARMAN_SERVER = "my_server"
BACKUP_ID = "backup_id"
SSH_COMMAND = "ssh postgres@localhost"
DATA_DIRECTORY = "/path/to/pgdata"
LOOP_WAIT = 10
RETRY_WAIT = 2
MAX_RETRIES = 5
class TestBarmanRecover(unittest.TestCase):
@patch.object(BarmanRecover, "_ensure_api_ok", Mock())
@patch("patroni.scripts.barman_recover.PoolManager", MagicMock())
def setUp(self):
self.br = BarmanRecover(API_URL, BARMAN_SERVER, BACKUP_ID, SSH_COMMAND, DATA_DIRECTORY, LOOP_WAIT, RETRY_WAIT,
MAX_RETRIES)
# Reset the mock as the same instance is used across tests
self.br.http.request.reset_mock()
self.br.http.request.side_effect = None
def test__build_full_url(self):
self.assertEqual(self.br._build_full_url("/some/path"), f"{API_URL}/some/path")
@patch("json.loads")
def test__deserialize_response(self, mock_json_loads):
mock_response = MagicMock()
self.assertIsNotNone(self.br._deserialize_response(mock_response))
mock_json_loads.assert_called_once_with(mock_response.data.decode("utf-8"))
@patch("json.dumps")
def test__serialize_request(self, mock_json_dumps):
body = "some_body"
ret = self.br._serialize_request(body)
self.assertIsNotNone(ret)
mock_json_dumps.assert_called_once_with(body)
mock_json_dumps.return_value.encode.assert_called_once_with("utf-8")
@patch.object(BarmanRecover, "_deserialize_response", Mock(return_value="test"))
@patch("logging.critical")
def test__get_request(self, mock_logging):
mock_request = self.br.http.request
# with no error
self.assertEqual(self.br._get_request("/some/path"), "test")
mock_request.assert_called_once_with("GET", f"{API_URL}/some/path")
# with MaxRetryError
http_error = MaxRetryError(self.br.http, f"{API_URL}/some/path")
mock_request.side_effect = http_error
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br._get_request("/some/path"))
mock_logging.assert_called_once_with("An error occurred while performing an HTTP GET request: %r", http_error)
self.assertEqual(exc.exception.code, ExitCode.HTTP_REQUEST_ERROR)
# with Exception
mock_logging.reset_mock()
mock_request.side_effect = Exception("Some error.")
with patch("sys.exit") as mock_sys:
with self.assertRaises(Exception):
self.assertIsNone(self.br._get_request("/some/path"))
mock_logging.assert_not_called()
mock_sys.assert_not_called()
@patch.object(BarmanRecover, "_deserialize_response", Mock(return_value="test"))
@patch("logging.critical")
@patch.object(BarmanRecover, "_serialize_request")
def test__post_request(self, mock_serialize, mock_logging):
mock_request = self.br.http.request
# with no error
self.assertEqual(self.br._post_request("/some/path", "some body"), "test")
mock_serialize.assert_called_once_with("some body")
mock_request.assert_called_once_with("POST", f"{API_URL}/some/path", body=mock_serialize.return_value,
headers={"Content-Type": "application/json"})
# with HTTPError
http_error = MaxRetryError(self.br.http, f"{API_URL}/some/path")
mock_request.side_effect = http_error
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br._post_request("/some/path", "some body"))
mock_logging.assert_called_once_with("An error occurred while performing an HTTP POST request: %r", http_error)
self.assertEqual(exc.exception.code, ExitCode.HTTP_REQUEST_ERROR)
# with Exception
mock_logging.reset_mock()
mock_request.side_effect = Exception("Some error.")
with patch("sys.exit") as mock_sys:
with self.assertRaises(Exception):
self.br._post_request("/some/path", "some body")
mock_logging.assert_not_called()
mock_sys.assert_not_called()
@patch("logging.critical")
@patch.object(BarmanRecover, "_get_request")
def test__ensure_api_ok(self, mock_get_request, mock_logging):
# API ok
mock_get_request.return_value = "OK"
with patch("sys.exit") as mock_sys:
self.assertIsNone(self.br._ensure_api_ok())
mock_logging.assert_not_called()
mock_sys.assert_not_called()
# API not ok
mock_get_request.return_value = "random"
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br._ensure_api_ok())
mock_logging.assert_called_once_with("pg-backup-api is not working: %s", "random")
self.assertEqual(exc.exception.code, ExitCode.API_NOT_OK)
@patch("logging.warning")
@patch("time.sleep")
@patch.object(BarmanRecover, "_post_request")
def test__create_recovery_operation(self, mock_post_request, mock_sleep, mock_logging):
# well formed response
mock_post_request.return_value = {"operation_id": "some_id"}
self.assertEqual(self.br._create_recovery_operation(), "some_id")
mock_sleep.assert_not_called()
mock_logging.assert_not_called()
mock_post_request.assert_called_once_with(
f"servers/{BARMAN_SERVER}/operations",
{
"type": "recovery",
"backup_id": BACKUP_ID,
"remote_ssh_command": SSH_COMMAND,
"destination_directory": DATA_DIRECTORY,
}
)
# malformed response
mock_post_request.return_value = {"operation_idd": "some_id"}
with self.assertRaises(RetriesExceeded) as exc:
self.br._create_recovery_operation()
self.assertEqual(str(exc.exception),
"Maximum number of retries exceeded for method BarmanRecover._create_recovery_operation.")
self.assertEqual(mock_sleep.call_count, self.br.max_retries)
mock_sleep.assert_has_calls([mock.call(self.br.retry_wait)] * self.br.max_retries)
self.assertEqual(mock_logging.call_count, self.br.max_retries)
for i in range(mock_logging.call_count):
call_args = mock_logging.call_args_list[i][0]
self.assertEqual(len(call_args), 5)
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
self.assertEqual(call_args[1], i + 1)
self.assertEqual(call_args[2], self.br.max_retries)
self.assertEqual(call_args[3], "BarmanRecover._create_recovery_operation")
self.assertIsInstance(call_args[4], KeyError)
self.assertEqual(call_args[4].args, ('operation_id',))
@patch("logging.warning")
@patch("time.sleep")
@patch.object(BarmanRecover, "_get_request")
def test__get_recovery_operation_status(self, mock_get_request, mock_sleep, mock_logging):
# well formed response
mock_get_request.return_value = {"status": "some status"}
self.assertEqual(self.br._get_recovery_operation_status("some_id"), "some status")
mock_get_request.assert_called_once_with(f"servers/{BARMAN_SERVER}/operations/some_id")
mock_sleep.assert_not_called()
mock_logging.assert_not_called()
# malformed response
mock_get_request.return_value = {"statuss": "some status"}
with self.assertRaises(RetriesExceeded) as exc:
self.br._get_recovery_operation_status("some_id")
self.assertEqual(str(exc.exception),
"Maximum number of retries exceeded for method BarmanRecover._get_recovery_operation_status.")
self.assertEqual(mock_sleep.call_count, self.br.max_retries)
mock_sleep.assert_has_calls([mock.call(self.br.retry_wait)] * self.br.max_retries)
self.assertEqual(mock_logging.call_count, self.br.max_retries)
for i in range(mock_logging.call_count):
call_args = mock_logging.call_args_list[i][0]
self.assertEqual(len(call_args), 5)
self.assertEqual(call_args[0], "Attempt %d of %d on method %s failed with %r.")
self.assertEqual(call_args[1], i + 1)
self.assertEqual(call_args[2], self.br.max_retries)
self.assertEqual(call_args[3], "BarmanRecover._get_recovery_operation_status")
self.assertIsInstance(call_args[4], KeyError)
self.assertEqual(call_args[4].args, ('status',))
@patch.object(BarmanRecover, "_get_recovery_operation_status")
@patch("time.sleep")
@patch("logging.info")
@patch("logging.critical")
@patch.object(BarmanRecover, "_create_recovery_operation")
def test_restore_backup(self, mock_create_op, mock_log_critical, mock_log_info, mock_sleep, mock_get_status):
# successful fast restore
mock_create_op.return_value = "some_id"
mock_get_status.return_value = "DONE"
self.assertTrue(self.br.restore_backup())
mock_create_op.assert_called_once()
mock_get_status.assert_called_once_with("some_id")
mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id")
mock_log_critical.assert_not_called()
mock_sleep.assert_not_called()
# successful slow restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_get_status.side_effect = ["IN_PROGRESS"] * 20 + ["DONE"]
self.assertTrue(self.br.restore_backup())
mock_create_op.assert_called_once()
self.assertEqual(mock_get_status.call_count, 21)
mock_get_status.assert_has_calls([mock.call("some_id")] * 21)
self.assertEqual(mock_log_info.call_count, 21)
mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")]
+ [mock.call("Recovery operation %s is still in progress", "some_id")] * 20)
mock_log_critical.assert_not_called()
self.assertEqual(mock_sleep.call_count, 20)
mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20)
# failed fast restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_sleep.reset_mock()
mock_get_status.side_effect = None
mock_get_status.return_value = "FAILED"
self.assertFalse(self.br.restore_backup())
mock_create_op.assert_called_once()
mock_get_status.assert_called_once_with("some_id")
mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id")
mock_log_critical.assert_not_called()
mock_sleep.assert_not_called()
# failed slow restore
mock_create_op.reset_mock()
mock_get_status.reset_mock()
mock_log_info.reset_mock()
mock_sleep.reset_mock()
mock_get_status.side_effect = ["IN_PROGRESS"] * 20 + ["FAILED"]
self.assertFalse(self.br.restore_backup())
mock_create_op.assert_called_once()
self.assertEqual(mock_get_status.call_count, 21)
mock_get_status.assert_has_calls([mock.call("some_id")] * 21)
self.assertEqual(mock_log_info.call_count, 21)
mock_log_info.assert_has_calls([mock.call("Created the recovery operation with ID %s", "some_id")]
+ [mock.call("Recovery operation %s is still in progress", "some_id")] * 20)
mock_log_critical.assert_not_called()
self.assertEqual(mock_sleep.call_count, 20)
mock_sleep.assert_has_calls([mock.call(LOOP_WAIT)] * 20)
# create retries exceeded
mock_log_info.reset_mock()
mock_sleep.reset_mock()
mock_create_op.side_effect = RetriesExceeded
mock_get_status.side_effect = None
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br.restore_backup())
self.assertEqual(exc.exception.code, ExitCode.HTTP_RESPONSE_MALFORMED)
mock_log_info.assert_not_called()
mock_log_critical.assert_called_once_with("Maximum number of retries exceeded, exiting.")
mock_sleep.assert_not_called()
# get status retries exceeded
mock_create_op.reset_mock()
mock_create_op.side_effect = None
mock_log_critical.reset_mock()
mock_log_info.reset_mock()
mock_get_status.side_effect = RetriesExceeded
with self.assertRaises(SystemExit) as exc:
self.assertIsNone(self.br.restore_backup())
self.assertEqual(exc.exception.code, ExitCode.HTTP_RESPONSE_MALFORMED)
mock_log_info.assert_called_once_with("Created the recovery operation with ID %s", "some_id")
mock_log_critical.assert_called_once_with("Maximum number of retries exceeded, exiting.")
mock_sleep.assert_not_called()
class TestMain(unittest.TestCase):
@patch("logging.basicConfig")
def test_set_up_logging(self, mock_log_config):
log_file = "/path/to/some/file.log"
set_up_logging(log_file)
mock_log_config.assert_called_once_with(filename=log_file, level=logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")
@patch("logging.critical")
@patch("logging.info")
@patch("patroni.scripts.barman_recover.set_up_logging")
@patch("patroni.scripts.barman_recover.BarmanRecover")
@patch("patroni.scripts.barman_recover.ArgumentParser")
def test_main(self, mock_arg_parse, mock_br, mock_set_up_log, mock_log_info, mock_log_critical):
# successful restore
args = MagicMock()
mock_arg_parse.return_value.parse_known_args.return_value = (args, None)
mock_br.return_value.restore_backup.return_value = True
with self.assertRaises(SystemExit) as exc:
main()
mock_arg_parse.assert_called_once()
mock_set_up_log.assert_called_once_with(args.log_file)
mock_br.assert_called_once_with(args.api_url, args.barman_server, args.backup_id, args.ssh_command,
args.data_directory, args.loop_wait, args.retry_wait, args.max_retries,
args.cert_file, args.key_file)
mock_log_info.assert_called_once_with("Recovery operation finished successfully.")
mock_log_critical.assert_not_called()
self.assertEqual(exc.exception.code, ExitCode.RECOVERY_DONE)
# failed restore
mock_arg_parse.reset_mock()
mock_set_up_log.reset_mock()
mock_br.reset_mock()
mock_log_info.reset_mock()
mock_br.return_value.restore_backup.return_value = False
with self.assertRaises(SystemExit) as exc:
main()
mock_arg_parse.assert_called_once()
mock_set_up_log.assert_called_once_with(args.log_file)
mock_br.assert_called_once_with(args.api_url, args.barman_server, args.backup_id, args.ssh_command,
args.data_directory, args.loop_wait, args.retry_wait, args.max_retries,
args.cert_file, args.key_file)
mock_log_info.assert_not_called()
mock_log_critical.assert_called_once_with("Recovery operation failed.")
self.assertEqual(exc.exception.code, ExitCode.RECOVERY_FAILED)
+15 -1
View File
@@ -1,6 +1,7 @@
import time
from mock import Mock, patch
from mock import Mock, patch, PropertyMock
from patroni.postgresql.citus import CitusHandler
from patroni.psycopg import ProgrammingError
from . import BaseTestPostgresql, MockCursor, psycopg_connect, SleepException
from .test_ha import get_cluster_initialized_with_leader
@@ -161,3 +162,16 @@ class TestCitus(BaseTestPostgresql):
'type': 'logical', 'database': 'citus', 'plugin': 'pgoutput'}))
self.assertTrue(self.c.ignore_replication_slot({'name': 'citus_shard_split_slot_1_2_3',
'type': 'logical', 'database': 'citus', 'plugin': 'citus'}))
@patch('patroni.postgresql.citus.logger.debug')
@patch('patroni.postgresql.citus.connect', psycopg_connect)
@patch('patroni.postgresql.citus.quote_ident', Mock())
def test_bootstrap_duplicate_database(self, mock_logger):
with patch.object(MockCursor, 'execute', Mock(side_effect=ProgrammingError)):
self.assertRaises(ProgrammingError, self.c.bootstrap)
with patch.object(MockCursor, 'execute', Mock(side_effect=[ProgrammingError, None, None, None])), \
patch.object(ProgrammingError, 'diag') as mock_diag:
type(mock_diag).sqlstate = PropertyMock(return_value='42P04')
self.c.bootstrap()
mock_logger.assert_called_once()
self.assertTrue(mock_logger.call_args[0][0].startswith('Exception when creating database'))
+27 -28
View File
@@ -5,11 +5,7 @@ import io
from copy import deepcopy
from mock import MagicMock, Mock, patch
from patroni import global_config
from patroni.config import ClusterConfig, Config, ConfigParseError
from .test_ha import get_cluster_initialized_with_only_leader
from patroni.config import Config, ConfigParseError, GlobalConfig
class TestConfig(unittest.TestCase):
@@ -155,38 +151,43 @@ class TestConfig(unittest.TestCase):
def test_invalid_path(self):
self.assertRaises(ConfigParseError, Config, 'postgres0')
@patch.object(Config, 'get')
@patch('patroni.config.logger')
def test__validate_failover_tags(self, mock_logger):
def test__validate_failover_tags(self, mock_logger, mock_get):
"""Ensures that only one of `nofailover` or `failover_priority` can be provided"""
config = Config("postgres0.yml")
# Providing one of `nofailover` or `failover_priority` is fine
for tags_config in [{"nofailover": True}, {"failover_priority": 1}]:
self.assertIsNone(Config._validate_failover_tags(tags_config))
for single_param in ({"nofailover": True}, {"failover_priority": 1}, {"failover_priority": 0}):
mock_get.side_effect = [single_param] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_not_called()
# Providing both `nofailover` and `failover_priority` is fine if consistent
for tags_config in [
{"nofailover": False, "failover_priority": 1},
{"nofailover": True, "failover_priority": 0}]:
self.assertIsNone(Config._validate_failover_tags(tags_config))
self.assertIn('nofailover', tags_config)
self.assertIn('failover_priority', tags_config)
for consistent_state in (
{"nofailover": False, "failover_priority": 1},
{"nofailover": True, "failover_priority": 0},
{"nofailover": "False", "failover_priority": 0}
):
mock_get.side_effect = [consistent_state] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_not_called()
# Providing both inconsistently should log a warning
for tags_config in [
{"nofailover": False, "failover_priority": 0},
{"nofailover": True, "failover_priority": 1}]:
initial_config = tags_config.copy()
self.assertIsNone(Config._validate_failover_tags(tags_config))
self.assertIn('nofailover', tags_config)
self.assertNotIn('failover_priority', tags_config)
for inconsistent_state in (
{"nofailover": False, "failover_priority": 0},
{"nofailover": True, "failover_priority": 1},
{"nofailover": "False", "failover_priority": 1},
{"nofailover": "", "failover_priority": 0}
):
mock_get.side_effect = [inconsistent_state] * 2
self.assertIsNone(config._validate_failover_tags())
mock_logger.warning.assert_called_once_with(
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
+ ' Defaulting to nofailover: %s',
initial_config['nofailover'],
initial_config['failover_priority'],
initial_config['nofailover']
)
inconsistent_state['nofailover'],
inconsistent_state['failover_priority'],
inconsistent_state['nofailover'])
mock_logger.warning.reset_mock()
def test__process_postgresql_parameters(self):
@@ -239,6 +240,4 @@ class TestConfig(unittest.TestCase):
def test_global_config_is_synchronous_mode(self):
# we should ignore synchronous_mode setting in a standby cluster
config = {'standby_cluster': {'host': 'some_host'}, 'synchronous_mode': True}
cluster = get_cluster_initialized_with_only_leader(cluster_config=ClusterConfig(1, config, 1))
test_config = global_config.from_cluster(cluster)
self.assertFalse(test_config.is_synchronous_mode)
self.assertFalse(GlobalConfig(config).is_synchronous_mode)
+256 -195
View File
@@ -1,4 +1,3 @@
import click
import etcd
import mock
import os
@@ -7,11 +6,10 @@ import unittest
from click.testing import CliRunner
from datetime import datetime, timedelta
from mock import patch, Mock, PropertyMock
from patroni import global_config
from patroni.ctl import ctl, load_config, output_members, get_dcs, parse_dcs, \
get_all_members, get_any_member, get_cursor, query_member, PatroniCtlException, apply_config_changes, \
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH, PatronictlPrettyTable
from patroni.dcs import Cluster, Failover
from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Cluster, Failover
from patroni.psycopg import OperationalError
from patroni.utils import tzutc
from prettytable import PrettyTable, ALL
@@ -23,26 +21,26 @@ from .test_ha import get_cluster_initialized_without_leader, get_cluster_initial
get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader, get_cluster, Member
def get_default_config(*args):
return {
'scope': 'alpha',
'restapi': {'listen': '::', 'certfile': 'a'},
'ctl': {'certfile': 'a'},
'etcd': {'host': 'localhost:2379', 'retry_timeout': 10, 'ttl': 30},
'citus': {'database': 'citus', 'group': 0},
'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}
}
DEFAULT_CONFIG = {
'scope': 'alpha',
'restapi': {'listen': '::', 'certfile': 'a'},
'ctl': {'certfile': 'a'},
'etcd': {'host': 'localhost:2379'},
'citus': {'database': 'citus', 'group': 0},
'postgresql': {'data_dir': '.', 'pgpass': './pgpass', 'parameters': {}, 'retry_timeout': 5}
}
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
@patch('patroni.ctl.load_config', get_default_config)
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
@patch('patroni.ctl.load_config', Mock(return_value=DEFAULT_CONFIG))
class TestCtl(unittest.TestCase):
TEST_ROLES = ('master', 'primary', 'leader')
@patch('socket.getaddrinfo', socket_getaddrinfo)
@patch.object(AbstractEtcdClientWithFailover, '_get_machines_list', Mock(return_value=['http://remotehost:2379']))
def setUp(self):
self.runner = CliRunner()
self.e = get_dcs({'etcd': {'ttl': 30, 'host': 'ok:2379', 'retry_timeout': 10},
'citus': {'group': 0}}, 'foo', None)
@patch('patroni.ctl.logging.debug')
def test_load_config(self, mock_logger_debug):
@@ -68,31 +66,29 @@ class TestCtl(unittest.TestCase):
@patch('patroni.psycopg.connect', psycopg_connect)
def test_get_cursor(self):
with click.Context(click.Command('query')) as ctx:
ctx.obj = {'__config': {}}
for role in self.TEST_ROLES:
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), None, {}, role=role))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {}, role=role))
for role in self.TEST_ROLES:
self.assertIsNone(get_cursor({}, get_cluster_initialized_without_leader(), None, {}, role=role))
self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {}, role=role))
# MockCursor returns pg_is_in_recovery as false
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), None, {}, role='replica'))
# MockCursor returns pg_is_in_recovery as false
self.assertIsNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {}, role='replica'))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, role='any'))
self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, role='any'))
# Mutually exclusive options
with self.assertRaises(PatroniCtlException) as e:
get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, member_name='other',
role='replica')
# Mutually exclusive options
with self.assertRaises(PatroniCtlException) as e:
get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'}, member_name='other',
role='replica')
self.assertEqual(str(e.exception), '--role and --member are mutually exclusive options')
self.assertEqual(str(e.exception), '--role and --member are mutually exclusive options')
# Invalid member provided
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
member_name='invalid'))
# Invalid member provided
self.assertIsNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
member_name='invalid'))
# Valid member provided
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
member_name='other'))
# Valid member provided
self.assertIsNotNone(get_cursor({}, get_cluster_initialized_with_leader(), None, {'dbname': 'foo'},
member_name='other'))
def test_parse_dcs(self):
assert parse_dcs(None) is None
@@ -106,20 +102,23 @@ class TestCtl(unittest.TestCase):
self.assertRaises(PatroniCtlException, parse_dcs, 'invalid://test')
def test_output_members(self):
with click.Context(click.Command('list')) as ctx:
ctx.obj = {'__config': {}}
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at))
del cluster.members[1].data['conn_url']
for fmt in ('pretty', 'json', 'yaml', 'topology'):
self.assertIsNone(output_members(cluster, name='abc', fmt=fmt))
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
cluster = get_cluster_initialized_with_leader(Failover(1, 'foo', 'bar', scheduled_at))
del cluster.members[1].data['conn_url']
for fmt in ('pretty', 'json', 'yaml', 'topology'):
self.assertIsNone(output_members({}, cluster, name='abc', fmt=fmt))
with patch('click.echo') as mock_echo:
self.assertIsNone(output_members(cluster, name='abc', fmt='tsv'))
self.assertEqual(mock_echo.call_args[0][0], 'abc\tother\t\tReplica\trunning\t\tunknown')
with patch('click.echo') as mock_echo:
self.assertIsNone(output_members({}, cluster, name='abc', fmt='tsv'))
self.assertEqual(mock_echo.call_args[0][0], 'abc\tother\t\tReplica\trunning\t\tunknown')
@patch('patroni.ctl.get_dcs')
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
def test_switchover(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
mock_get_dcs.return_value.set_failover_value = Mock()
@patch('patroni.dcs.AbstractDCS.set_failover_value', Mock())
def test_switchover(self):
# Confirm
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
self.assertEqual(result.exit_code, 0)
@@ -148,7 +147,7 @@ class TestCtl(unittest.TestCase):
self.assertEqual(result.exit_code, 0)
# Scheduled in pause mode
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
'--force', '--scheduled', '2015-01-01T12:00:00'])
self.assertEqual(result.exit_code, 1)
@@ -157,7 +156,8 @@ class TestCtl(unittest.TestCase):
# Target and source are equal
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nleader\n\ny')
self.assertEqual(result.exit_code, 1)
self.assertIn('Switchover target and source are the same', result.output)
self.assertIn("Candidate ['other']", result.output)
self.assertIn('Member leader is already the leader of cluster dummy', result.output)
# Candidate is not a member of the cluster
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nReality\n\ny')
@@ -181,12 +181,12 @@ class TestCtl(unittest.TestCase):
self.assertIn('Member dummy is not the leader of cluster dummy', result.output)
# Errors while sending Patroni REST API request
with patch('patroni.ctl.request_patroni', Mock(side_effect=Exception)):
with patch.object(PoolManager, 'request', Mock(side_effect=Exception)):
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'],
input='leader\nother\n2300-01-01T12:23:00\ny')
self.assertIn('falling back to DCS', result.output)
with patch('patroni.ctl.request_patroni') as mock_api_request:
with patch.object(PoolManager, 'request') as mock_api_request:
mock_api_request.return_value.status = 500
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
self.assertIn('Switchover failed', result.output)
@@ -197,60 +197,70 @@ class TestCtl(unittest.TestCase):
self.assertIn('Switchover failed', result.output)
# No members available
with patch('patroni.dcs.AbstractDCS.get_cluster',
Mock(return_value=get_cluster_initialized_with_only_leader())):
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
self.assertEqual(result.exit_code, 1)
self.assertIn('No candidates found to switchover to', result.output)
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_only_leader
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
self.assertEqual(result.exit_code, 1)
self.assertIn('No candidates found to switchover to', result.output)
# No leader available
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())):
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
self.assertEqual(result.exit_code, 1)
self.assertIn('This cluster has no leader', result.output)
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_without_leader
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
self.assertEqual(result.exit_code, 1)
self.assertIn('This cluster has no leader', result.output)
# Citus cluster, no group number specified
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--force'], input='\n')
self.assertEqual(result.exit_code, 1)
self.assertIn('For Citus clusters the --group must me specified', result.output)
@patch('patroni.dcs.AbstractDCS.set_failover_value', Mock())
def test_failover(self):
@patch('patroni.ctl.get_dcs')
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
@patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse()))
def test_failover(self, mock_get_dcs):
mock_get_dcs.return_value.set_failover_value = Mock()
# No candidate specified
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['failover', 'dummy'], input='0\n')
self.assertIn('Failover could be performed only to a specific candidate', result.output)
# Candidate is the same as the leader
result = self.runner.invoke(ctl, ['failover', 'dummy', '--group', '0'], input='leader\n')
self.assertIn("Candidate ['other']", result.output)
self.assertIn('Member leader is already the leader of cluster dummy', result.output)
# Temp test to check a fallback to switchover if leader is specified
with patch('patroni.ctl._do_failover_or_switchover') as failover_func_mock:
result = self.runner.invoke(ctl, ['failover', '--leader', 'leader', 'dummy'], input='0\n')
self.assertIn('Supplying a leader name using this command is deprecated', result.output)
failover_func_mock.assert_called_once_with('switchover', 'dummy', None, 'leader', None, False)
failover_func_mock.assert_called_once_with(
DEFAULT_CONFIG, 'switchover', 'dummy', None, 'leader', None, False)
cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
cluster.members.append(Member(0, 'async', 28, {'api_url': 'http://127.0.0.1:8012/patroni'}))
cluster.config.data['synchronous_mode'] = True
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)):
# Failover to an async member in sync mode (confirm)
result = self.runner.invoke(ctl,
['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='y\ny')
self.assertIn('Are you sure you want to failover to the asynchronous node async', result.output)
self.assertEqual(result.exit_code, 0)
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
# Failover to an async member in sync mode (confirm)
result = self.runner.invoke(ctl,
['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='y\ny')
self.assertIn('Are you sure you want to failover to the asynchronous node async', result.output)
self.assertEqual(result.exit_code, 0)
# Failover to an async member in sync mode (abort)
result = self.runner.invoke(ctl, ['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='N')
self.assertEqual(result.exit_code, 1)
self.assertIn('Aborting failover', result.output)
# Failover to an async member in sync mode (abort)
result = self.runner.invoke(ctl, ['failover', 'dummy', '--group', '0', '--candidate', 'async'], input='N')
self.assertEqual(result.exit_code, 1)
self.assertIn('Aborting failover', result.output)
@patch('patroni.dynamic_loader.iter_modules', Mock(return_value=['patroni.dcs.dummy', 'patroni.dcs.etcd']))
@patch('patroni.dcs.dcs_modules', Mock(return_value=['patroni.dcs.dummy', 'patroni.dcs.etcd']))
def test_get_dcs(self):
with click.Context(click.Command('list')) as ctx:
ctx.obj = {'__config': {'dummy': {}}}
self.assertRaises(PatroniCtlException, get_dcs, 'dummy', 0)
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy', 0)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
@patch('patroni.ctl.get_dcs')
@patch.object(etcd.Client, 'read', etcd_read)
def test_query(self):
def test_query(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
# Mutually exclusive
for role in self.TEST_ROLES:
result = self.runner.invoke(ctl, ['query', 'alpha', '--member', 'abc', '--role', role])
@@ -283,29 +293,31 @@ class TestCtl(unittest.TestCase):
def test_query_member(self):
with patch('patroni.ctl.get_cursor', Mock(return_value=MockConnect().cursor())):
for role in self.TEST_ROLES:
rows = query_member(None, None, None, None, role, 'SELECT pg_catalog.pg_is_in_recovery()', {})
rows = query_member({}, None, None, None, None, role, 'SELECT pg_catalog.pg_is_in_recovery()', {})
self.assertTrue('False' in str(rows))
with patch.object(MockCursor, 'execute', Mock(side_effect=OperationalError('bla'))):
rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
with patch('patroni.ctl.get_cursor', Mock(return_value=None)):
# No role nor member given -- generic message
rows = query_member(None, None, None, None, None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
rows = query_member({}, None, None, None, None, None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
self.assertTrue('No connection is available' in str(rows))
# Member given -- message pointing to member
rows = query_member(None, None, None, 'foo', None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
rows = query_member({}, None, None, None, 'foo', None, 'SELECT pg_catalog.pg_is_in_recovery()', {})
self.assertTrue('No connection to member foo' in str(rows))
# Role given -- message pointing to role
rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
self.assertTrue('No connection to role replica' in str(rows))
with patch('patroni.ctl.get_cursor', Mock(side_effect=OperationalError('bla'))):
rows = query_member(None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
rows = query_member({}, None, None, None, None, 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
def test_dsn(self):
@patch('patroni.ctl.get_dcs')
def test_dsn(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['dsn', 'alpha'])
assert 'host=127.0.0.1 port=5435' in result.output
@@ -318,8 +330,11 @@ class TestCtl(unittest.TestCase):
result = self.runner.invoke(ctl, ['dsn', 'alpha', '--member', 'dummy'])
assert result.exit_code == 1
@patch('patroni.ctl.request_patroni')
def test_reload(self, mock_post):
@patch.object(PoolManager, 'request')
@patch('patroni.ctl.get_dcs')
def test_reload(self, mock_get_dcs, mock_post):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
assert 'Failed: reload for member' in result.output
@@ -331,8 +346,10 @@ class TestCtl(unittest.TestCase):
result = self.runner.invoke(ctl, ['reload', 'alpha'], input='y')
assert 'Reload request received for member' in result.output
@patch('patroni.ctl.request_patroni')
def test_restart_reinit(self, mock_post):
@patch.object(PoolManager, 'request')
@patch('patroni.ctl.get_dcs')
def test_restart_reinit(self, mock_get_dcs, mock_post):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
mock_post.return_value.status = 503
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='now\ny\n')
assert 'Failed: restart for' in result.output
@@ -372,7 +389,7 @@ class TestCtl(unittest.TestCase):
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
assert 'Failed: flush scheduled restart' in result.output
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
result = self.runner.invoke(ctl,
['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
assert result.exit_code == 1
@@ -407,10 +424,12 @@ class TestCtl(unittest.TestCase):
assert 'Failed: another restart is already' in result.output
assert result.exit_code == 0
def test_remove(self):
@patch('patroni.ctl.get_dcs')
def test_remove(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['remove', 'dummy'], input='\n')
assert 'For Citus clusters the --group must me specified' in result.output
result = self.runner.invoke(ctl, ['remove', 'alpha', '--group', '0'], input='alpha\nstandby')
result = self.runner.invoke(ctl, ['-k', 'remove', 'alpha', '--group', '0'], input='alpha\nstandby')
assert 'Please confirm' in result.output
assert 'You are about to remove all' in result.output
# Not typing an exact confirmation
@@ -428,36 +447,37 @@ class TestCtl(unittest.TestCase):
assert result.exit_code == 0
def test_ctl(self):
self.runner.invoke(ctl, ['list'])
result = self.runner.invoke(ctl, ['--help'])
assert 'Usage:' in result.output
def test_get_any_member(self):
with click.Context(click.Command('list')) as ctx:
ctx.obj = {'__config': {}}
for role in self.TEST_ROLES:
self.assertIsNone(get_any_member(get_cluster_initialized_without_leader(), None, role=role))
for role in self.TEST_ROLES:
self.assertIsNone(get_any_member({}, get_cluster_initialized_without_leader(), None, role=role))
m = get_any_member(get_cluster_initialized_with_leader(), None, role=role)
self.assertEqual(m.name, 'leader')
m = get_any_member({}, get_cluster_initialized_with_leader(), None, role=role)
self.assertEqual(m.name, 'leader')
def test_get_all_members(self):
with click.Context(click.Command('list')) as ctx:
ctx.obj = {'__config': {}}
for role in self.TEST_ROLES:
self.assertEqual(list(get_all_members(get_cluster_initialized_without_leader(), None, role=role)), [])
for role in self.TEST_ROLES:
self.assertEqual(list(get_all_members({}, get_cluster_initialized_without_leader(), None, role=role)), [])
r = list(get_all_members(get_cluster_initialized_with_leader(), None, role=role))
self.assertEqual(len(r), 1)
self.assertEqual(r[0].name, 'leader')
r = list(get_all_members(get_cluster_initialized_with_leader(), None, role='replica'))
r = list(get_all_members({}, get_cluster_initialized_with_leader(), None, role=role))
self.assertEqual(len(r), 1)
self.assertEqual(r[0].name, 'other')
self.assertEqual(r[0].name, 'leader')
self.assertEqual(len(list(get_all_members(get_cluster_initialized_without_leader(),
None, role='replica'))), 2)
r = list(get_all_members({}, get_cluster_initialized_with_leader(), None, role='replica'))
self.assertEqual(len(r), 1)
self.assertEqual(r[0].name, 'other')
self.assertEqual(len(list(get_all_members({}, get_cluster_initialized_without_leader(),
None, role='replica'))), 2)
@patch('patroni.ctl.get_dcs')
def test_members(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
def test_members(self):
result = self.runner.invoke(ctl, ['list'])
assert '127.0.0.1' in result.output
assert result.exit_code == 0
@@ -466,100 +486,127 @@ class TestCtl(unittest.TestCase):
result = self.runner.invoke(ctl, ['list', '--group', '0'])
assert 'Citus cluster: alpha (group: 0, 12345678901) -' in result.output
config = get_default_config()
del config['citus']
with patch('patroni.ctl.load_config', Mock(return_value=config)):
with patch('patroni.ctl.load_config', Mock(return_value={'scope': 'alpha'})):
result = self.runner.invoke(ctl, ['list'])
assert 'Cluster: alpha (12345678901) -' in result.output
with patch('patroni.ctl.load_config', Mock(return_value={})):
self.runner.invoke(ctl, ['list'])
def test_list_extended(self):
@patch('patroni.ctl.get_dcs')
def test_list_extended(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
result = self.runner.invoke(ctl, ['list', 'dummy', '--extended', '--timestamp'])
assert '2100' in result.output
assert 'Scheduled restart' in result.output
def test_topology(self):
@patch('patroni.ctl.get_dcs')
def test_topology(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
cluster = get_cluster_initialized_with_leader()
cluster.members.append(Member(0, 'cascade', 28,
{'conn_url': 'postgres://replicator:[email protected]:5437/postgres',
'api_url': 'http://127.0.0.1:8012/patroni', 'state': 'running',
'tags': {'replicatefrom': 'other'}}))
cluster.members.append(Member(0, 'wrong_cascade', 28,
{'conn_url': 'postgres://replicator:[email protected]:5438/postgres',
'api_url': 'http://127.0.0.1:8013/patroni', 'state': 'running',
'tags': {'replicatefrom': 'nonexistinghost'}}))
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=cluster)):
result = self.runner.invoke(ctl, ['topology', 'dummy'])
assert '+\n| 0 | leader | 127.0.0.1:5435 | Leader |' in result.output
assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output
assert '|\n| 0 | + cascade | 127.0.0.1:5437 | Replica |' in result.output
assert '|\n| 0 | + wrong_cascade | 127.0.0.1:5438 | Replica |' in result.output
cascade_member = Member(0, 'cascade', 28, {'conn_url': 'postgres://replicator:[email protected]:5437/postgres',
'api_url': 'http://127.0.0.1:8012/patroni',
'state': 'running',
'tags': {'replicatefrom': 'other'},
})
cascade_member_wrong_tags = Member(0, 'wrong_cascade', 28,
{'conn_url': 'postgres://replicator:[email protected]:5438/postgres',
'api_url': 'http://127.0.0.1:8013/patroni',
'state': 'running',
'tags': {'replicatefrom': 'nonexistinghost'},
})
cluster.members.append(cascade_member)
cluster.members.append(cascade_member_wrong_tags)
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
result = self.runner.invoke(ctl, ['topology', 'dummy'])
assert '+\n| 0 | leader | 127.0.0.1:5435 | Leader |' in result.output
assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output
assert '|\n| 0 | + cascade | 127.0.0.1:5437 | Replica |' in result.output
assert '|\n| 0 | + wrong_cascade | 127.0.0.1:5438 | Replica |' in result.output
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_without_leader())):
result = self.runner.invoke(ctl, ['topology', 'dummy'])
assert '+\n| 0 | + leader | 127.0.0.1:5435 | Replica |' in result.output
assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output
cluster = get_cluster_initialized_without_leader()
mock_get_dcs.return_value.get_cluster = Mock(return_value=cluster)
result = self.runner.invoke(ctl, ['topology', 'dummy'])
assert '+\n| 0 | + leader | 127.0.0.1:5435 | Replica |' in result.output
assert '|\n| 0 | + other | 127.0.0.1:5436 | Replica |' in result.output
@patch('patroni.ctl.get_dcs')
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
def test_flush_restart(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
def test_flush_restart(self):
for role in self.TEST_ROLES:
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '-r', role], input='y')
result = self.runner.invoke(ctl, ['-k', 'flush', 'dummy', 'restart', '-r', role], input='y')
assert 'No scheduled restart' in result.output
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
assert 'Success: flush scheduled restart' in result.output
with patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse(404))):
with patch.object(PoolManager, 'request', return_value=MockResponse(404)):
result = self.runner.invoke(ctl, ['flush', 'dummy', 'restart', '--force'])
assert 'Failed: flush scheduled restart' in result.output
def test_flush_switchover(self):
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader())):
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
assert 'No pending scheduled switchover' in result.output
@patch('patroni.ctl.get_dcs')
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
def test_flush_switchover(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
assert 'No pending scheduled switchover' in result.output
scheduled_at = datetime.now(tzutc) + timedelta(seconds=600)
with patch('patroni.dcs.AbstractDCS.get_cluster',
Mock(return_value=get_cluster_initialized_with_leader(Failover(1, 'a', 'b', scheduled_at)))):
result = self.runner.invoke(ctl, ['-k', 'flush', 'dummy', 'switchover'])
assert result.output.startswith('Success: ')
mock_get_dcs.return_value.get_cluster = Mock(
return_value=get_cluster_initialized_with_leader(Failover(1, 'a', 'b', scheduled_at)))
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
assert result.output.startswith('Success: ')
with patch('patroni.ctl.request_patroni', side_effect=[MockResponse(409), Exception]), \
patch('patroni.dcs.AbstractDCS.manual_failover', Mock()):
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
assert 'Could not find any accessible member of cluster' in result.output
mock_get_dcs.return_value.manual_failover = Mock()
with patch.object(PoolManager, 'request', side_effect=[MockResponse(409), Exception]):
result = self.runner.invoke(ctl, ['flush', 'dummy', 'switchover'])
assert 'Could not find any accessible member of cluster' in result.output
@patch.object(PoolManager, 'request')
@patch('patroni.ctl.get_dcs')
@patch('patroni.ctl.polling_loop', Mock(return_value=[1]))
def test_pause_cluster(self):
with patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse(500))):
result = self.runner.invoke(ctl, ['pause', 'dummy'])
assert 'Failed' in result.output
def test_pause_cluster(self, mock_get_dcs, mock_post):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
mock_post.return_value.status = 500
result = self.runner.invoke(ctl, ['pause', 'dummy'])
assert 'Failed' in result.output
mock_post.return_value.status = 200
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
result = self.runner.invoke(ctl, ['pause', 'dummy'])
assert 'Cluster is already paused' in result.output
result = self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
assert "'pause' request sent" in result.output
mock_get_dcs.return_value.get_cluster = Mock(side_effect=[get_cluster_initialized_with_leader(),
get_cluster(None, None, [], None, None)])
self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
member = Member(1, 'other', 28, {})
mock_get_dcs.return_value.get_cluster = Mock(side_effect=[get_cluster_initialized_with_leader(),
get_cluster(None, None, [member], None, None)])
self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
with patch('patroni.dcs.AbstractDCS.get_cluster',
Mock(side_effect=[get_cluster_initialized_with_leader(), get_cluster(None, None, [], None, None)])):
self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
with patch('patroni.dcs.AbstractDCS.get_cluster',
Mock(side_effect=[get_cluster_initialized_with_leader(),
get_cluster(None, None, [Member(1, 'other', 28, {})], None, None)])):
self.runner.invoke(ctl, ['pause', 'dummy', '--wait'])
@patch.object(PoolManager, 'request')
@patch('patroni.ctl.get_dcs')
def test_resume_cluster(self, mock_get_dcs, mock_post):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
@patch('patroni.ctl.request_patroni')
@patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=get_cluster_initialized_with_leader()))
def test_resume_cluster(self, mock_post):
mock_post.return_value.status = 200
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=False)):
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=False)):
result = self.runner.invoke(ctl, ['resume', 'dummy'])
assert 'Cluster is not paused' in result.output
with patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)):
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
result = self.runner.invoke(ctl, ['resume', 'dummy'])
assert 'Success' in result.output
@@ -661,53 +708,67 @@ class TestCtl(unittest.TestCase):
with patch('shutil.which', Mock(return_value=e)):
self.assertRaises(PatroniCtlException, invoke_editor, 'foo: bar\n', 'test')
def test_show_config(self):
@patch('patroni.ctl.get_dcs')
def test_show_config(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
self.runner.invoke(ctl, ['show-config', 'dummy'])
@patch('patroni.ctl.get_dcs')
@patch('subprocess.call', Mock(return_value=0))
def test_edit_config(self):
def test_edit_config(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
mock_get_dcs.return_value.set_config_value = Mock(return_value=False)
os.environ['EDITOR'] = 'true'
self.runner.invoke(ctl, ['edit-config', 'dummy'])
self.runner.invoke(ctl, ['edit-config', 'dummy', '-s', 'foo=bar'])
self.runner.invoke(ctl, ['edit-config', 'dummy', '--replace', 'postgres0.yml'])
self.runner.invoke(ctl, ['edit-config', 'dummy', '--apply', '-'], input='foo: bar')
self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar')
with patch('patroni.dcs.etcd.Etcd.set_config_value', Mock(return_value=True)):
self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar')
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=Cluster.empty())):
result = self.runner.invoke(ctl, ['edit-config', 'dummy'])
assert result.exit_code == 1
assert 'The config key does not exist in the cluster dummy' in result.output
mock_get_dcs.return_value.set_config_value.return_value = True
self.runner.invoke(ctl, ['edit-config', 'dummy', '--force', '--apply', '-'], input='foo: bar')
mock_get_dcs.return_value.get_cluster = Mock(return_value=Cluster.empty())
result = self.runner.invoke(ctl, ['edit-config', 'dummy'])
assert result.exit_code == 1
assert 'The config key does not exist in the cluster dummy' in result.output
@patch('patroni.ctl.request_patroni')
def test_version(self, mock_request):
result = self.runner.invoke(ctl, ['version'])
assert 'patronictl version' in result.output
mock_request.return_value.data = b'{"patroni":{"version":"1.2.3"},"server_version": 100001}'
result = self.runner.invoke(ctl, ['version', 'dummy'])
assert '1.2.3' in result.output
mock_request.side_effect = Exception
result = self.runner.invoke(ctl, ['version', 'dummy'])
assert 'failed to get version' in result.output
@patch('patroni.ctl.get_dcs')
def test_version(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
with patch.object(PoolManager, 'request') as mocked:
result = self.runner.invoke(ctl, ['version'])
assert 'patronictl version' in result.output
mocked.return_value.data = b'{"patroni":{"version":"1.2.3"},"server_version": 100001}'
result = self.runner.invoke(ctl, ['version', 'dummy'])
assert '1.2.3' in result.output
with patch.object(PoolManager, 'request', Mock(side_effect=Exception)):
result = self.runner.invoke(ctl, ['version', 'dummy'])
assert 'failed to get version' in result.output
def test_history(self):
with patch('patroni.dcs.AbstractDCS.get_cluster') as mock_get_cluster:
mock_get_cluster.return_value.history.lines = [[1, 67176, 'no recovery target specified']]
result = self.runner.invoke(ctl, ['history'])
assert 'Reason' in result.output
@patch('patroni.ctl.get_dcs')
def test_history(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = Mock()
mock_get_dcs.return_value.get_cluster.return_value.history.lines = [[1, 67176, 'no recovery target specified']]
result = self.runner.invoke(ctl, ['history'])
assert 'Reason' in result.output
def test_format_pg_version(self):
self.assertEqual(format_pg_version(100001), '10.1')
self.assertEqual(format_pg_version(90605), '9.6.5')
def test_get_members(self):
with patch('patroni.dcs.AbstractDCS.get_cluster',
Mock(return_value=get_cluster_not_initialized_without_leader())):
result = self.runner.invoke(ctl, ['reinit', 'dummy'])
assert "cluster doesn\'t have any members" in result.output
@patch('patroni.ctl.get_dcs')
def test_get_members(self, mock_get_dcs):
mock_get_dcs.return_value = self.e
mock_get_dcs.return_value.get_cluster = get_cluster_not_initialized_without_leader
result = self.runner.invoke(ctl, ['reinit', 'dummy'])
assert "cluster doesn\'t have any members" in result.output
@patch('time.sleep', Mock())
def test_reinit_wait(self):
@patch('patroni.ctl.get_dcs')
def test_reinit_wait(self, mock_get_dcs):
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
with patch.object(PoolManager, 'request') as mocked:
mocked.side_effect = [Mock(data=s, status=200) for s in
[b"reinitialize", b'{"state":"creating replica"}', b'{"state":"running"}']]
+14 -18
View File
@@ -4,7 +4,6 @@ import os
import sys
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
from patroni import global_config
from patroni.collections import CaseInsensitiveSet
from patroni.config import Config
from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, Status, SyncState, TimelineHistory
@@ -218,7 +217,6 @@ class TestHa(PostgresInit):
self.ha = Ha(MockPatroni(self.p, self.e))
self.ha.old_cluster = self.e.get_cluster()
self.ha.cluster = get_cluster_initialized_without_leader()
global_config.update(self.ha.cluster)
self.ha.load_cluster_from_dcs = Mock()
def test_update_lock(self):
@@ -253,10 +251,8 @@ class TestHa(PostgresInit):
@patch('patroni.dcs.etcd.Etcd.initialize', return_value=True)
def test_bootstrap_as_standby_leader(self, initialize):
self.p.data_directory_empty = true
self.ha.cluster = get_cluster_not_initialized_without_leader(
cluster_config=ClusterConfig(1, {"standby_cluster": {"port": 5432}}, 1))
global_config.update(self.ha.cluster)
self.ha.cluster = get_cluster_not_initialized_without_leader(cluster_config=ClusterConfig(0, {}, 0))
self.ha.patroni.config._dynamic_configuration = {"standby_cluster": {"port": 5432}}
self.assertEqual(self.ha.run_cycle(), 'trying to bootstrap a new standby leader')
def test_bootstrap_waiting_for_standby_leader(self):
@@ -322,7 +318,7 @@ class TestHa(PostgresInit):
self.ha.state_handler.cancellable._process = Mock()
self.ha._crash_recovery_started -= 600
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 10})
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
self.assertEqual(self.ha.run_cycle(), 'terminated crash recovery because of startup timeout')
@patch.object(Rewind, 'ensure_clean_shutdown', Mock())
@@ -513,7 +509,7 @@ class TestHa(PostgresInit):
def test_check_failsafe_topology(self):
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
self.ha.dcs._last_failsafe = self.ha.cluster.failsafe
self.assertEqual(self.ha.run_cycle(), 'demoting self because DCS is not accessible and I was a leader')
self.ha.state_handler.name = self.ha.cluster.leader.name
@@ -533,7 +529,7 @@ class TestHa(PostgresInit):
def test_no_dcs_connection_primary_failsafe(self):
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
self.ha.dcs._last_failsafe = self.ha.cluster.failsafe
self.ha.state_handler.name = self.ha.cluster.leader.name
self.assertEqual(self.ha.run_cycle(),
@@ -550,7 +546,7 @@ class TestHa(PostgresInit):
def test_no_dcs_connection_replica_failsafe(self):
self.ha.load_cluster_from_dcs = Mock(side_effect=DCSError('Etcd is not responding properly'))
self.ha.cluster = get_cluster_initialized_with_leader_and_failsafe()
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
self.ha.update_failsafe({'name': 'leader', 'api_url': 'http://127.0.0.1:8008/patroni',
'conn_url': 'postgres://127.0.0.1:5432/postgres', 'slots': {'foo': 1000}})
self.p.is_primary = false
@@ -770,7 +766,7 @@ class TestHa(PostgresInit):
with patch('patroni.ha.logger.info') as mock_info:
self.ha.fetch_node_status = get_node_status(wal_position=1)
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5})
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.assertEqual(mock_info.call_args_list[0][0], ('Member %s exceeds maximum replication lag', 'leader'))
@@ -1036,7 +1032,7 @@ class TestHa(PostgresInit):
def test__is_healthiest_node(self):
self.p.is_primary = false
self.ha.cluster = get_cluster_initialized_without_leader(sync=('postgresql1', self.p.name))
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
@@ -1053,7 +1049,7 @@ class TestHa(PostgresInit):
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
self.ha.cluster.config.data.update({'maximum_lag_on_failover': 5})
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
with patch('patroni.postgresql.Postgresql.last_operation', return_value=1):
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
with patch('patroni.postgresql.Postgresql.replica_cached_timeline', return_value=None):
@@ -1276,7 +1272,7 @@ class TestHa(PostgresInit):
self.p.is_running = false
self.ha.cluster = get_cluster_initialized_with_leader(sync=(self.p.name, 'other'))
self.ha.cluster.config.data.update({'synchronous_mode': True, 'primary_start_timeout': 0})
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
self.ha.has_lock = true
self.ha.update_lock = true
self.ha.fetch_node_status = get_node_status() # accessible, in_recovery
@@ -1286,13 +1282,13 @@ class TestHa(PostgresInit):
def test_primary_stop_timeout(self):
self.assertEqual(self.ha.primary_stop_timeout(), None)
self.ha.cluster.config.data.update({'primary_stop_timeout': 30})
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
self.assertEqual(self.ha.primary_stop_timeout(), 30)
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=False)):
self.assertEqual(self.ha.primary_stop_timeout(), None)
self.ha.cluster.config.data['primary_stop_timeout'] = None
global_config.update(self.ha.cluster)
self.ha.global_config = self.ha.patroni.config.get_global_config(self.ha.cluster)
self.assertEqual(self.ha.primary_stop_timeout(), None)
@patch('patroni.postgresql.Postgresql.follow')
@@ -1384,9 +1380,8 @@ class TestHa(PostgresInit):
# Test sync set to '*' when synchronous_mode_strict is enabled
mock_set_sync.reset_mock()
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet(), CaseInsensitiveSet()))
self.ha.cluster.config.data['synchronous_mode_strict'] = True
global_config.update(self.ha.cluster)
self.ha.run_cycle()
with patch('patroni.config.GlobalConfig.is_synchronous_mode_strict', PropertyMock(return_value=True)):
self.ha.run_cycle()
mock_set_sync.assert_called_once_with(CaseInsensitiveSet('*'))
def test_sync_replication_become_primary(self):
@@ -1519,6 +1514,7 @@ class TestHa(PostgresInit):
@patch('patroni.postgresql.mtime', Mock(return_value=1588316884))
@patch('builtins.open', Mock(side_effect=Exception))
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
def test_restore_cluster_config(self):
self.ha.cluster.config.data.clear()
self.ha.has_lock = true
+16 -1
View File
@@ -154,7 +154,6 @@ class TestPatroni(unittest.TestCase):
self.p.api.start = Mock()
self.p.logger.start = Mock()
self.p.config._dynamic_configuration = {}
self.assertRaises(SleepException, self.p.run)
with patch('patroni.dcs.Cluster.is_unlocked', Mock(return_value=True)):
self.assertRaises(SleepException, self.p.run)
with patch('patroni.config.Config.reload_local_configuration', Mock(return_value=False)):
@@ -175,6 +174,20 @@ class TestPatroni(unittest.TestCase):
self.p.next_run = time.time() - self.p.dcs.loop_wait - 1
self.p.schedule_next_run()
def test__filter_tags(self):
tags = {'noloadbalance': False, 'clonefrom': False, 'nosync': False, 'smth': 'random'}
self.assertEqual(self.p._filter_tags(tags), {'smth': 'random'})
tags['clonefrom'] = True
tags['smth'] = False
self.assertEqual(self.p._filter_tags(tags), {'clonefrom': True, 'smth': False})
tags = {'nofailover': False, 'failover_priority': 0}
self.assertEqual(self.p._filter_tags(tags), tags)
tags = {'nofailover': True, 'failover_priority': 1}
self.assertEqual(self.p._filter_tags(tags), tags)
def test_noloadbalance(self):
self.p.tags['noloadbalance'] = True
self.assertTrue(self.p.noloadbalance)
@@ -186,9 +199,11 @@ class TestPatroni(unittest.TestCase):
# Setting `nofailover: True` has precedence
(True, 0, True),
(True, 1, True),
('False', 1, True), # because we use bool() for the value
# Similarly, setting `nofailover: False` has precedence
(False, 0, False),
(False, 1, False),
('', 0, False),
# Only when we have `nofailover: None` should we got based on priority
(None, 0, True),
(None, 1, False),
+46 -19
View File
@@ -10,14 +10,15 @@ from mock import Mock, MagicMock, PropertyMock, patch, mock_open
import patroni.psycopg as psycopg
from patroni import global_config
from patroni.async_executor import CriticalTask
from patroni.collections import CaseInsensitiveSet
from patroni.config import GlobalConfig
from patroni.dcs import RemoteMember
from patroni.exceptions import PostgresConnectionException, PatroniException
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.callback_executor import CallbackAction
from patroni.postgresql.config import _false_validator
from patroni.postgresql.postmaster import PostmasterProcess
from patroni.postgresql.validator import (ValidatorFactoryNoType, ValidatorFactoryInvalidType,
ValidatorFactoryInvalidSpec, ValidatorFactory, InvalidGucValidatorsFile,
@@ -574,6 +575,14 @@ class TestPostgresql(BaseTestPostgresql):
mock_info.reset_mock()
# Ignored params changed
config['parameters']['archive_cleanup_command'] = 'blabla'
self.p.reload_config(config)
mock_info.assert_called_once_with('No PostgreSQL configuration items changed, nothing to reload.')
self.assertEqual(self.p.pending_restart, False)
mock_info.reset_mock()
# Handle wal_buffers
self.p.config._config['parameters']['wal_buffers'] = '512'
self.p.reload_config(config)
@@ -766,12 +775,12 @@ class TestPostgresql(BaseTestPostgresql):
def test_get_server_parameters(self):
config = {'parameters': {'wal_level': 'hot_standby', 'max_prepared_transactions': 100}, 'listen': '0'}
with patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True)):
self.p.config.get_server_parameters(config)
with patch.object(global_config.__class__, 'is_synchronous_mode_strict', PropertyMock(return_value=True)):
self.p.config.get_server_parameters(config)
self.p.config.set_synchronous_standby_names('foo')
self.assertTrue(str(self.p.config.get_server_parameters(config)).startswith('<CaseInsensitiveDict'))
self.p._global_config = GlobalConfig({'synchronous_mode': True})
self.p.config.get_server_parameters(config)
self.p._global_config = GlobalConfig({'synchronous_mode': True, 'synchronous_mode_strict': True})
self.p.config.get_server_parameters(config)
self.p.config.set_synchronous_standby_names('foo')
self.assertTrue(str(self.p.config.get_server_parameters(config)).startswith('<CaseInsensitiveDict'))
@patch('time.sleep', Mock())
def test__wait_for_connection_close(self):
@@ -800,21 +809,28 @@ class TestPostgresql(BaseTestPostgresql):
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='replica'))
@patch.object(Postgresql, 'is_running', Mock(return_value=False))
@patch.object(Bootstrap, 'running_custom_bootstrap', PropertyMock(return_value=True))
@patch.object(Postgresql, 'controldata', Mock(return_value={'max_connections setting': '200',
'max_worker_processes setting': '20',
'max_locks_per_xact setting': '100',
'max_wal_senders setting': 10}))
@patch('patroni.postgresql.config.logger.warning')
@patch('patroni.postgresql.config.logger')
def test_effective_configuration(self, mock_logger):
self.p.cancellable.cancel()
self.p.config.write_recovery_conf({'pause_at_recovery_target': 'false'})
self.assertFalse(self.p.start())
mock_logger.assert_called_once()
self.assertTrue('is missing from pg_controldata output' in mock_logger.call_args[0][0])
controldata = {'max_connections setting': '100', 'max_worker_processes setting': '8',
'max_locks_per_xact setting': '64', 'max_wal_senders setting': 5}
self.assertTrue(self.p.pending_restart)
with patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
with patch.object(Postgresql, 'controldata', Mock(return_value=controldata)), \
patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True)):
self.p.cancellable.cancel()
self.assertFalse(self.p.start())
self.assertFalse(self.p.pending_restart)
mock_logger.warning.assert_called_once()
self.assertEqual(mock_logger.warning.call_args[0],
('%s is missing from pg_controldata output', 'max_prepared_xacts setting'))
mock_logger.reset_mock()
controldata['max_prepared_xacts setting'] = 0
controldata['max_wal_senders setting'] *= 2
with patch.object(Postgresql, 'controldata', Mock(return_value=controldata)):
self.p.config.write_recovery_conf({'pause_at_recovery_target': 'false'})
self.assertFalse(self.p.start())
mock_logger.warning.assert_not_called()
self.assertTrue(self.p.pending_restart)
@patch('os.path.exists', Mock(return_value=True))
@@ -1058,3 +1074,14 @@ class TestPostgresql2(BaseTestPostgresql):
self.assertIn('diff(pg_catalog.pg_current_xlog_flush_location(', self.p.cluster_info_query)
self.p._major_version = 90500
self.assertIn('diff(pg_catalog.pg_current_xlog_location(', self.p.cluster_info_query)
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
@patch.object(Postgresql, '_query', Mock(return_value=[('primary_conninfo', 'host=a port=5433 passfile=/blabla')]))
def test_load_current_server_parameters(self):
keep_values = {name: self.p.config._server_parameters[name]
for name, value in self.p.config.CMDLINE_OPTIONS.items() if value[1] == _false_validator}
self.p.config.load_current_server_parameters()
self.assertTrue(all(self.p.config._server_parameters[name] == value for name, value in keep_values.items()))
self.assertEqual(dict(self.p.config._recovery_params),
{'primary_conninfo': {'host': 'a', 'port': '5433', 'passfile': '/blabla',
'gssencmode': 'prefer', 'sslmode': 'prefer', 'channel_binding': 'prefer'}})
+27 -39
View File
@@ -6,23 +6,16 @@ import unittest
from mock import Mock, PropertyMock, patch
from threading import Thread
from patroni import global_config, psycopg
from patroni import psycopg
from patroni.config import GlobalConfig
from patroni.dcs import Cluster, ClusterConfig, Member, Status, SyncState
from patroni.postgresql import Postgresql
from patroni.postgresql.misc import fsync_dir
from patroni.postgresql.slots import SlotsAdvanceThread, SlotsHandler
from patroni.tags import Tags
from . import BaseTestPostgresql, psycopg_connect, MockCursor
class TestTags(Tags):
@property
def tags(self):
return {}
@patch('subprocess.call', Mock(return_value=0))
@patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(Thread, 'start', Mock())
@@ -36,13 +29,12 @@ class TestSlotsHandler(BaseTestPostgresql):
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
def setUp(self):
super(TestSlotsHandler, self).setUp()
self.p._global_config = GlobalConfig({})
self.s = self.p.slots_handler
self.p.start()
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}, 'ls2': None}}, 1)
self.cluster = Cluster(True, config, self.leader, Status(0, {'ls': 12345, 'ls2': 12345}),
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
global_config.update(self.cluster)
self.tags = TestTags()
def test_sync_replication_slots(self):
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
@@ -50,38 +42,36 @@ class TestSlotsHandler(BaseTestPostgresql):
'ignore_slots': [{'name': 'blabla'}]}, 1)
cluster = Cluster(True, config, self.leader, Status(0, {'test_3': 10}),
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
global_config.update(cluster)
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg.OperationalError)):
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
self.p.set_role('standby_leader')
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=(True, False))), \
patch.object(global_config.__class__, 'is_standby_cluster', PropertyMock(return_value=True)), \
patch.object(GlobalConfig, 'is_standby_cluster', PropertyMock(return_value=True)), \
patch('patroni.postgresql.slots.logger.debug') as mock_debug:
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
mock_debug.assert_called_once()
self.p.set_role('replica')
with patch.object(Postgresql, 'is_primary', Mock(return_value=False)), \
patch.object(global_config.__class__, 'is_paused', PropertyMock(return_value=True)), \
patch.object(SlotsHandler, 'drop_replication_slot') as mock_drop:
config.data['slots'].pop('ls')
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False, paused=True)
mock_drop.assert_not_called()
self.p.set_role('primary')
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
with patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
cluster.members.extend([alias1, alias2])
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
self.assertEqual(errorlog_mock.call_count, 5)
ca = errorlog_mock.call_args_list[0][0][1]
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90618)):
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
self.p.set_role('replica')
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
def test_cascading_replica_sync_replication_slots(self):
"""Test sync with a cascading replica so physical slots are present on a replica."""
@@ -96,7 +86,7 @@ class TestSlotsHandler(BaseTestPostgresql):
with patch.object(Postgresql, '_query') as mock_query, \
patch.object(Postgresql, 'is_primary', Mock(return_value=False)):
mock_query.return_value = [('ls', 'logical', 104, 'b', 'a', 5, 12345, 105)]
ret = self.s.sync_replication_slots(cluster, self.tags)
ret = self.s.sync_replication_slots(cluster, False)
self.assertEqual(ret, [])
def test_process_permanent_slots(self):
@@ -104,9 +94,8 @@ class TestSlotsHandler(BaseTestPostgresql):
'ignore_slots': [{'name': 'blabla'}]}, 1)
cluster = Cluster(True, config, self.leader, Status.empty(), [self.me, self.other, self.leadermem],
None, SyncState.empty(), None, None)
global_config.update(cluster)
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
with patch.object(Postgresql, '_query') as mock_query:
self.p.reset_cluster_info_state(None)
mock_query.return_value = [(
@@ -129,48 +118,48 @@ class TestSlotsHandler(BaseTestPostgresql):
self.p.set_role('replica')
self.cluster.slots['ls'] = 12346
with patch.object(SlotsHandler, 'check_logical_slots_readiness', Mock(return_value=False)):
self.assertEqual(self.s.sync_replication_slots(self.cluster, self.tags), [])
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), [])
with patch.object(SlotsHandler, '_query', Mock(return_value=[('ls', 'logical', 499, 'b', 'a', 5, 100, 500)])), \
patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)), \
patch.object(SlotsAdvanceThread, 'schedule', Mock(return_value=(True, ['ls']))), \
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
self.assertEqual(self.s.sync_replication_slots(self.cluster, self.tags), ['ls'])
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), ['ls'])
self.cluster.slots['ls'] = 'a'
self.assertEqual(self.s.sync_replication_slots(self.cluster, self.tags), [])
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), [])
self.cluster.config.data['slots']['ls']['database'] = 'b'
self.cluster.slots['ls'] = '500'
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
self.assertEqual(self.s.sync_replication_slots(self.cluster, self.tags), ['ls'])
self.assertEqual(self.s.sync_replication_slots(self.cluster, False), ['ls'])
def test_copy_logical_slots(self):
self.cluster.config.data['slots']['ls']['database'] = 'b'
self.s.copy_logical_slots(self.cluster, self.tags, ['ls'])
self.s.copy_logical_slots(self.cluster, ['ls'])
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)):
self.s.copy_logical_slots(self.cluster, self.tags, ['foo'])
self.s.copy_logical_slots(self.cluster, ['foo'])
with patch.object(Cluster, 'leader', PropertyMock(return_value=None)):
self.s.copy_logical_slots(self.cluster, self.tags, ['foo'])
self.s.copy_logical_slots(self.cluster, ['foo'])
@patch.object(Postgresql, 'stop', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True))
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
def test_check_logical_slots_readiness(self):
self.s.copy_logical_slots(self.cluster, self.tags, ['ls'])
self.s.copy_logical_slots(self.cluster, ['ls'])
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
patch.object(MockCursor, 'fetchall', Mock(side_effect=Exception)):
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, self.tags))
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, None))
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('postgresql0', None)]))), \
patch.object(MockCursor, 'fetchall', Mock(return_value=[(False,)])):
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, self.tags))
self.assertFalse(self.s.check_logical_slots_readiness(self.cluster, None))
with patch.object(MockCursor, '__iter__', Mock(return_value=iter([('ls', 100)]))):
self.s.check_logical_slots_readiness(self.cluster, self.tags)
self.s.check_logical_slots_readiness(self.cluster, None)
@patch.object(Postgresql, 'stop', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True))
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
def test_on_promote(self):
self.s.schedule_advance_slots({'foo': {'bar': 100}})
self.s.copy_logical_slots(self.cluster, self.tags, ['ls'])
self.s.copy_logical_slots(self.cluster, ['ls'])
self.s.on_promote()
@unittest.skipIf(os.name == 'nt', "Windows not supported")
@@ -200,12 +189,11 @@ class TestSlotsHandler(BaseTestPostgresql):
config = ClusterConfig(1, {'slots': {'blabla': {'type': 'physical'}, 'leader': None}}, 1)
cluster = Cluster(True, config, self.leader, Status(0, {'blabla': 12346}),
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
global_config.update(cluster)
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
with patch.object(SlotsHandler, '_query', Mock(side_effect=[[('blabla', 'physical', 12345, None, None, None,
None, None)], Exception])) as mock_query, \
patch('patroni.postgresql.slots.logger.error') as mock_error:
self.s.sync_replication_slots(cluster, self.tags)
self.s.sync_replication_slots(cluster, False)
self.assertEqual(mock_query.call_args[0],
("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", "blabla", '0/303A'))
self.assertEqual(mock_error.call_args[0][0],
+4 -3
View File
@@ -1,9 +1,9 @@
import os
from mock import Mock, patch, PropertyMock
from mock import Mock, patch
from patroni import global_config
from patroni.collections import CaseInsensitiveSet
from patroni.config import GlobalConfig
from patroni.dcs import Cluster, SyncState
from patroni.postgresql import Postgresql
@@ -13,7 +13,6 @@ from . import BaseTestPostgresql, psycopg_connect, mock_available_gucs
@patch('subprocess.call', Mock(return_value=0))
@patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(Postgresql, 'available_gucs', mock_available_gucs)
@patch.object(global_config.__class__, 'is_synchronous_mode', PropertyMock(return_value=True))
class TestSync(BaseTestPostgresql):
@patch('subprocess.call', Mock(return_value=0))
@@ -25,6 +24,7 @@ class TestSync(BaseTestPostgresql):
def setUp(self):
super(TestSync, self).setUp()
self.p.config.write_postgresql_conf()
self.p._global_config = GlobalConfig({'synchronous_mode': True})
self.s = self.p.sync_handler
@patch.object(Postgresql, 'last_operation', Mock(return_value=1))
@@ -96,6 +96,7 @@ class TestSync(BaseTestPostgresql):
self.assertEqual(value_in_conf(), None)
mock_reload.reset_mock()
self.p._global_config = GlobalConfig({'synchronous_mode': True})
self.s.set_synchronous_standby_names(CaseInsensitiveSet('*'))
mock_reload.assert_called()
self.assertEqual(value_in_conf(), "synchronous_standby_names = '*'")