mirror of
https://github.com/outbackdingo/patroni.git
synced 2026-08-26 15:40:21 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce10e5fccc | ||
|
|
bb90feb393 | ||
|
|
3d527f5728 | ||
|
|
6c06f5cc96 | ||
|
|
f5ee67fa1c | ||
|
|
65030c56ee | ||
|
|
d471f1156d | ||
|
|
6d98944e73 | ||
|
|
6cfd90401e | ||
|
|
ce187bec38 | ||
|
|
c5fffb3c97 | ||
|
|
cb5f34b721 | ||
|
|
260ab36f2e | ||
|
|
fc67ba73f0 | ||
|
|
60d8bc3a70 | ||
|
|
e513f7f127 | ||
|
|
aa3ebe0af8 | ||
|
|
c96e35c807 | ||
|
|
88b35252c3 | ||
|
|
d93db20baa | ||
|
|
42976df86f | ||
|
|
6f4c2fe132 | ||
|
|
588df5da05 | ||
|
|
fb367cd73e | ||
|
|
535dc631ec | ||
|
|
9b8c40a6e1 | ||
|
|
e19a8730ea | ||
|
|
28a604983b | ||
|
|
efacc6c16b | ||
|
|
9283ebda64 | ||
|
|
a329a9d320 | ||
|
|
f77073c8e1 | ||
|
|
aaac6f6fb0 | ||
|
|
27915984b4 | ||
|
|
220cacd95f | ||
|
|
a3b3e1bc1c | ||
|
|
c855b0bff9 | ||
|
|
4c1c804cfd | ||
|
|
48514db84b | ||
|
|
2bd821a768 | ||
|
|
fe16c3610e | ||
|
|
bc15813de0 | ||
|
|
18d9cb1124 | ||
|
|
66bdb1ae12 | ||
|
|
28b9d3d2d9 | ||
|
|
25ceb68257 | ||
|
|
5a504e67c1 | ||
|
|
75dbe4ff96 | ||
|
|
71863cedcb | ||
|
|
728abfcc37 | ||
|
|
238b8db91e |
@@ -46,7 +46,7 @@ def install_packages(what):
|
||||
packages = packages.get(what, [])
|
||||
ver = versions.get(what)
|
||||
if float(ver) >= 15:
|
||||
packages += ['postgresql-{0}-citus-11.2'.format(ver)]
|
||||
packages += ['postgresql-{0}-citus-12.1'.format(ver)]
|
||||
subprocess.call(['sudo', 'apt-get', 'update', '-y'])
|
||||
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev'] + packages)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
versions = {'etcd': '9.6', 'etcd3': '16', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '15'}
|
||||
|
||||
@@ -24,8 +24,11 @@ jobs:
|
||||
- name: Run tests and flake8
|
||||
run: python .github/workflows/run_tests.py
|
||||
|
||||
- name: Install Python packaging build frontend
|
||||
run: python -m pip install build
|
||||
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: python setup.py sdist bdist_wheel
|
||||
run: python -m build
|
||||
|
||||
- name: Publish distribution to Test PyPI
|
||||
if: github.event_name == 'push'
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- 'REL_[0-9]+_[0-9]+'
|
||||
|
||||
env:
|
||||
CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
|
||||
@@ -173,7 +174,7 @@ jobs:
|
||||
|
||||
- uses: jakebailey/pyright-action@v1
|
||||
with:
|
||||
version: 1.1.320
|
||||
version: 1.1.333
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+12
-19
@@ -12,7 +12,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely.
|
||||
|
||||
Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
Currently supported PostgreSQL versions: 9.3 to 16.
|
||||
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the `Citus support page <https://github.com/zalando/patroni/blob/master/docs/citus.rst>`__ in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
|
||||
|
||||
@@ -77,23 +77,8 @@ There are a few options available:
|
||||
sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu
|
||||
sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
|
||||
|
||||
2. Install psycopg2 from the binary package
|
||||
2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the list of dependencies when installing Patroni with pip (see below).
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg2-binary
|
||||
|
||||
3. Install psycopg2 from source
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg2>=2.5.4
|
||||
|
||||
4. Use psycopg 3.0 instead of psycopg2
|
||||
|
||||
::
|
||||
|
||||
pip install psycopg[binary]>=3.0.0
|
||||
|
||||
**General installation for pip**
|
||||
|
||||
@@ -119,12 +104,20 @@ raft
|
||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||
aws
|
||||
`boto3` in order to use AWS callbacks
|
||||
all
|
||||
all of the above (except psycopg family)
|
||||
psycopg3
|
||||
`psycopg[binary]>=3.0.0` module
|
||||
psycopg2
|
||||
`psycopg2>=2.5.4` module
|
||||
psycopg2-binary
|
||||
`psycopg2-binary` module
|
||||
|
||||
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
|
||||
For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is:
|
||||
|
||||
::
|
||||
|
||||
pip install patroni[etcd,aws]
|
||||
pip install patroni[psycopg3,etcd3,aws]
|
||||
|
||||
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed independently of Patroni.
|
||||
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ Example session:
|
||||
| demo | patroni3 | 172.22.0.4 | | running | 1 | 0 |
|
||||
+---------+----------+------------+--------+---------+----+-----------+
|
||||
|
||||
postgres@patroni1:~$ etcdctl ls --recursive --sort -p /service/demo
|
||||
postgres@patroni1:~$ etcdctl get --keys-only --prefix /service/demo
|
||||
/service/demo/config
|
||||
/service/demo/initialize
|
||||
/service/demo/leader
|
||||
|
||||
+3
-12
@@ -24,15 +24,6 @@ Log
|
||||
- **PATRONI\_LOG\_FILE\_SIZE**: Size of patroni.log file (in bytes) that triggers a log rolling.
|
||||
- **PATRONI\_LOG\_LOGGERS**: Redefine logging level per python module. Example ``PATRONI_LOG_LOGGERS="{patroni.postmaster: WARNING, urllib3: DEBUG}"``
|
||||
|
||||
Bootstrap configuration
|
||||
-----------------------
|
||||
It is possible to create new database users right after the successful initialization of a new cluster. This process is defined by the following variables:
|
||||
|
||||
- **PATRONI\_<username>\_PASSWORD='<password>'**
|
||||
- **PATRONI\_<username>\_OPTIONS='list,of,options'**
|
||||
|
||||
Example: defining ``PATRONI_admin_PASSWORD=strongpasswd`` and ``PATRONI_admin_OPTIONS='createrole,createdb'`` will cause creation of the user **admin** with the password **strongpasswd** that is allowed to create other users and databases.
|
||||
|
||||
Citus
|
||||
-----
|
||||
Enables integration Patroni with `Citus <https://docs.citusdata.com>`__. If configured, Patroni will take care of registering Citus worker nodes on the coordinator. You can find more information about Citus support :ref:`here <citus>`.
|
||||
@@ -209,10 +200,10 @@ REST API
|
||||
CTL
|
||||
---
|
||||
- **PATRONICTL\_CONFIG\_FILE**: (optional) location of the configuration file.
|
||||
- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
|
||||
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
|
||||
- **PATRONI\_CTL\_USERNAME**: (optional) Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter.
|
||||
- **PATRONI\_CTL\_PASSWORD**: (optional) Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
|
||||
- **PATRONI\_CTL\_INSECURE**: (optional) Allow connections to REST API without verifying SSL certs.
|
||||
- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
|
||||
- **PATRONI\_CTL\_CACERT**: (optional) Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter.
|
||||
- **PATRONI\_CTL\_CERTFILE**: (optional) Specifies the file with the client certificate in the PEM format.
|
||||
- **PATRONI\_CTL\_KEYFILE**: (optional) Specifies the file with the client secret key in the PEM format.
|
||||
- **PATRONI\_CTL\_KEYFILE\_PASSWORD**: (optional) Specifies a password for decrypting the client keyfile.
|
||||
|
||||
+16
-12
@@ -38,14 +38,18 @@ After that you just need to start Patroni and it will handle the rest:
|
||||
2. If ``max_prepared_transactions`` isn't explicitly set in the global
|
||||
:ref:`dynamic configuration <dynamic_configuration>` Patroni will
|
||||
automatically set it to ``2*max_connections``.
|
||||
3. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``.
|
||||
4. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo``
|
||||
3. The ``citus.local_hostname`` GUC value will be adjusted from ``localhost`` to the
|
||||
value that Patroni is using in order to connect to the local PostgreSQL
|
||||
instance. The value sometimes should be different from the ``localhost``
|
||||
because PostgreSQL might be not listening on it.
|
||||
4. The ``citus.database`` will be automatically created followed by ``CREATE EXTENSION citus``.
|
||||
5. Current superuser :ref:`credentials <postgresql_settings>` will be added to the ``pg_dist_authinfo``
|
||||
table to allow cross-node communication. Don't forget to update them if
|
||||
later you decide to change superuser username/password/sslcert/sslkey!
|
||||
5. The coordinator primary node will automatically discover worker primary
|
||||
6. The coordinator primary node will automatically discover worker primary
|
||||
nodes and add them to the ``pg_dist_node`` table using the
|
||||
``citus_add_node()`` function.
|
||||
6. Patroni will also maintain ``pg_dist_node`` in case failover/switchover
|
||||
7. Patroni will also maintain ``pg_dist_node`` in case failover/switchover
|
||||
on the coordinator or worker clusters occurs.
|
||||
|
||||
patronictl
|
||||
@@ -57,7 +61,7 @@ clusters that are just logically groupped together using the
|
||||
PostgreSQL. Therefore in most cases it is not possible to manage them as a
|
||||
single entity.
|
||||
|
||||
It results in two major differences in ``patronictl`` behaviour when
|
||||
It results in two major differences in :ref:`patronictl` behaviour when
|
||||
``patroni.yaml`` has the ``citus`` section comparing with the usual:
|
||||
|
||||
1. The ``list`` and the ``topology`` by default output all members of the Citus
|
||||
@@ -65,12 +69,12 @@ It results in two major differences in ``patronictl`` behaviour when
|
||||
which Citus group they belong to.
|
||||
2. For all ``patronictl`` commands the new option is introduced, named
|
||||
``--group``. For some commands the default value for the group might be
|
||||
taken from the ``patroni.yaml``. For example, ``patronictl pause`` will
|
||||
taken from the ``patroni.yaml``. For example, :ref:`patronictl_pause` will
|
||||
enable the maintenance mode by default for the ``group`` that is set in the
|
||||
``citus`` section, but for example for ``patronictl switchover`` or
|
||||
``patronictl remove`` the group must be explicitly specified.
|
||||
``citus`` section, but for example for :ref:`patronictl_switchover` or
|
||||
:ref:`patronictl_remove` the group must be explicitly specified.
|
||||
|
||||
An example of ``patronictl list`` output for the Citus cluster::
|
||||
An example of :ref:`patronictl_list` output for the Citus cluster::
|
||||
|
||||
postgres@coord1:~$ patronictl list demo
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
@@ -115,7 +119,7 @@ the coordinator for the shards hosted on a worker node. The switchover then
|
||||
happens while the traffic is kept on the coordinator, and resumes as soon as a
|
||||
new primary worker node is ready to accept read-write queries.
|
||||
|
||||
An example of ``patronictl switchover`` on the worker cluster::
|
||||
An example of :ref:`patronictl_switchover` on the worker cluster::
|
||||
|
||||
postgres@coord1:~$ patronictl switchover demo
|
||||
+ Citus cluster: demo ----------+--------------+---------+----+-----------+
|
||||
@@ -140,7 +144,7 @@ An example of ``patronictl switchover`` on the worker cluster::
|
||||
| work2-1 | 172.27.0.5 | Sync Standby | running | 1 | 0 |
|
||||
| work2-2 | 172.27.0.7 | Leader | running | 1 | |
|
||||
+---------+------------+--------------+---------+----+-----------+
|
||||
Are you sure you want to perform a switchover in the cluster demo, demoting current primary work2-2? [y/N]: y
|
||||
Are you sure you want to switchover cluster demo, demoting current primary work2-2? [y/N]: y
|
||||
2022-12-22 07:02:40.33003 Successfully switched over to "work2-1"
|
||||
+ Citus cluster: demo (group: 2, 7179854924063375386) ------+
|
||||
| Member | Host | Role | State | TL | Lag in MB |
|
||||
@@ -343,7 +347,7 @@ Citus upgrades and PostgreSQL major upgrades
|
||||
|
||||
First, please read about upgrading Citus version in the `documentation`__.
|
||||
There is one minor change in the process. When executing upgrade, you have to
|
||||
use ``patronictl restart`` instead of ``systemctl restart`` to restart
|
||||
use :ref:`patronictl_restart` instead of ``systemctl restart`` to restart
|
||||
PostgreSQL.
|
||||
|
||||
__ https://docs.citusdata.com/en/latest/admin_guide/upgrading_citus.html
|
||||
|
||||
+1
-1
@@ -112,10 +112,10 @@ todo_include_todos = True
|
||||
# a list of builtin themes.
|
||||
#
|
||||
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
if not on_rtd: # only import and set the theme if we're building docs locally
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
|
||||
@@ -3,21 +3,25 @@
|
||||
Contributing guidelines
|
||||
=======================
|
||||
|
||||
Wanna contribute to Patroni? Yay - here is how!
|
||||
|
||||
Chatting
|
||||
--------
|
||||
|
||||
Just want to chat with other Patroni users? Looking for interactive troubleshooting help? Join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
|
||||
If you have a question, looking for an interactive troubleshooting help or want to chat with other Patroni users, join us on channel `#patroni <https://postgresteam.slack.com/archives/C9XPYG92A>`__ in the `PostgreSQL Slack <https://pgtreats.info/slack-invite>`__.
|
||||
|
||||
Reporting bugs
|
||||
--------------
|
||||
|
||||
Before reporting a bug please make sure to **reproduce it with the latest Patroni version**!
|
||||
Also please double check if the issue already exists in our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
Requirements for running behave tests:
|
||||
|
||||
1. PostgreSQL packages need to be installed.
|
||||
2. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
|
||||
3. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
|
||||
#. PostgreSQL packages including `contrib <https://www.postgresql.org/docs/current/contrib.html>`__ modules need to be installed.
|
||||
#. PostgreSQL binaries must be available in your `PATH`. You may need to add them to the path with something like `PATH=/usr/lib/postgresql/11/bin:$PATH python -m behave`.
|
||||
#. If you'd like to test with external DCSs (e.g., Etcd, Consul, and Zookeeper) you'll need the packages installed and respective services running and accepting unencrypted/unprotected connections on localhost and default port. In the case of Etcd or Consul, the behave test suite could start them up if binaries are available in the `PATH`.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
@@ -39,6 +43,13 @@ After you have all dependencies installed, you can run the various test suites:
|
||||
# Run the pytest suite in tests/:
|
||||
python setup.py test
|
||||
|
||||
# Moreover, you may want to run tests in different scopes for debugging purposes,
|
||||
# the -s option include print output during test execution.
|
||||
# Tests in pytest typically follow the pattern: FILEPATH::CLASSNAME::TESTNAME.
|
||||
pytest -s tests/test_api.py
|
||||
pytest -s tests/test_api.py::TestRestApiHandler
|
||||
pytest -s tests/test_api.py::TestRestApiHandler::test_do_GET
|
||||
|
||||
# Run the behave (https://behave.readthedocs.io/en/latest/) test suite in features/;
|
||||
# modify DCS as desired (raft has no dependencies so is the easiest to start with):
|
||||
DCS=raft python -m behave
|
||||
@@ -143,12 +154,12 @@ If you want to disable this facility set the env var `OPEN_CMD` to the `:` no-op
|
||||
Behave tests
|
||||
^^^^^^^^^^^^
|
||||
|
||||
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 15 and then run all
|
||||
Behave tests with `-m behave` will build docker images based on PG_MAJOR version 11 through 16 and then run all
|
||||
behave tests. This can take quite a long time to run so you might want to limit the scope to a select version of
|
||||
Postgres or to a specific feature set or steps.
|
||||
|
||||
To specify the version of postgres include the full name of the dependent image build env that you want and then the
|
||||
behave env name. For instance if you want Postgres 15 use:
|
||||
behave env name. For instance if you want Postgres 14 use:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -163,19 +174,12 @@ the watchdog behave feature test scenario with all versions of Postgres.
|
||||
|
||||
Of course you can combine the two.
|
||||
|
||||
Reporting issues
|
||||
----------------
|
||||
|
||||
If you have a question about patroni or have a problem using it, please read the :ref:`README <readme>` before filing an issue.
|
||||
Also double check with the current issues on our `Issues Tracker <https://github.com/zalando/patroni/issues>`__.
|
||||
|
||||
Contributing a pull request
|
||||
---------------------------
|
||||
|
||||
1) Submit a comment to the relevant issue or create a new issue describing your proposed change.
|
||||
2) Do a fork, develop and test your code changes.
|
||||
3) Include documentation
|
||||
4) Submit a pull request.
|
||||
#. Fork the repository, develop and test your code changes.
|
||||
#. Reflect changes in the user documentation.
|
||||
#. Submit a pull request with a clear description of the changes objective. Link an existing issue if necessary.
|
||||
|
||||
You'll get feedback about your pull request as soon as possible.
|
||||
|
||||
|
||||
@@ -60,4 +60,4 @@ F.A.Q.
|
||||
|
||||
- How to enable the Failsafe Mode?
|
||||
|
||||
Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API <rest_api>` or ``patronictl edit-config -s failsafe_mode=true``
|
||||
Before enabling the ``failsafe_mode`` please make sure that Patroni version on all members is up-to-date. After that, you can use either the ``PATCH /config`` :ref:`REST API <rest_api>` or :ref:`patronictl edit-config -s failsafe_mode=true <patronictl_edit_config_parameters>`
|
||||
|
||||
@@ -6,11 +6,20 @@ Dynamic Configuration Settings
|
||||
|
||||
Dynamic configuration is stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
|
||||
|
||||
In order to change the dynamic configuration you can use either ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
|
||||
In order to change the dynamic configuration you can use either :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
|
||||
|
||||
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10, minimum possible value: 1
|
||||
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30, minimum possible value: 20
|
||||
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10, minimum possible value: 3
|
||||
|
||||
.. warning::
|
||||
when changing values of **loop_wait**, **retry_timeout**, or **ttl** you have to follow the rule:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
loop_wait + 2 * retry_timeout <= ttl
|
||||
|
||||
|
||||
- **loop\_wait**: the number of seconds the loop will sleep. Default value: 10
|
||||
- **ttl**: the TTL to acquire the leader lock (in seconds). Think of it as the length of time before initiation of the automatic failover process. Default value: 30
|
||||
- **retry\_timeout**: timeout for DCS and PostgreSQL operation retries (in seconds). DCS or network issues shorter than this will not cause Patroni to demote the leader. Default value: 10
|
||||
- **maximum\_lag\_on\_failover**: the maximum bytes a follower may lag to be able to participate in leader election.
|
||||
- **maximum\_lag\_on\_syncnode**: the maximum bytes a synchronous follower may lag before it is considered as an unhealthy candidate and swapped by healthy asynchronous follower. Patroni utilize the max replica lsn if there is more than one follower, otherwise it will use leader's current wal lsn. Default is -1, Patroni will not take action to swap synchronous unhealthy follower when the value is set to 0 or below. Please set the value high enough so Patroni won't swap synchrounous follower fequently during high transaction volume.
|
||||
- **max\_timelines\_history**: maximum number of timeline history items kept in DCS. Default value: 0. When set to 0, it keeps the full history in DCS.
|
||||
@@ -46,9 +55,9 @@ In order to change the dynamic configuration you can use either ``patronictl edi
|
||||
- **archive\_cleanup\_command**: cleanup command for standby leader
|
||||
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
|
||||
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. The physical slots are maintained only in the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
|
||||
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Permanent slots that don't exist will be created by Patroni. With PostgreSQL 11 onwards permanent physical slots are created on all nodes and their position is advanced every **loop_wait** seconds. For PostgreSQL versions older than 11 permanent physical replication slots are maintained only on the current primary. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent replication slots requires **postgresql.use_slots** to be set to ``true``. If there are permanent logical replication slots defined Patroni will automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
|
||||
|
||||
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current leader it will not be created. Please note that Patroni does not make checks for permanent slot names added to this configuration matching those that Patroni creates automatically for members. If those names are added, Patroni will ensure that any slots that were created are not removed even if the member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS due to its effect on normal functioning of Patroni.
|
||||
- **my\_slot\_name**: the name of the permanent replication slot. If the permanent slot name matches with the name of the current node it will not be created on this node. If you add a permanent physical replication slot which name matches the name of a Patroni member, Patroni will ensure that the slot that was created is not removed even if the corresponding member becomes unresponsive, situation which would normally result in the slot's removal by Patroni. Although this can be useful in some situations, such as when you want replication slots used by members to persist during temporary failures or when importing existing members to a new Patroni cluster (see :ref:`Convert a Standalone to a Patroni Cluster <existing_data>` for details), caution should be exercised by the operator that these clashes in names are not persisted in the DCS, when the slot is no longer required, due to its effect on normal functioning of Patroni.
|
||||
|
||||
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
|
||||
- **database**: the database name where logical slots should be created.
|
||||
@@ -80,4 +89,22 @@ Note: **slots** is a hashmap while **ignore_slots** is an array. For example:
|
||||
plugin: test_decoding
|
||||
- name: ignored_physical_slot_name
|
||||
type: physical
|
||||
...
|
||||
...
|
||||
|
||||
Note: if cluster topology is static (fixed number of nodes that never change their names) you can configure permanent physical replication slots with names corresponding to names of nodes to avoid recycling of WAL files while replica is temporary down:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
slots:
|
||||
node_name1:
|
||||
type: physical
|
||||
node_name2:
|
||||
type: physical
|
||||
node_name3:
|
||||
type: physical
|
||||
...
|
||||
|
||||
|
||||
.. warning::
|
||||
Permanent replication slots are synchronized only from the ``primary``/``standby_leader`` to replica nodes. That means, applications are supposed to be using them only from the leader node. Using them on replica nodes will cause indefinite growth of ``pg_wal`` on all other nodes in the cluster.
|
||||
An exception to that rule are permanent physical slots that match the Patroni member names, if you happen to configure any. Those will be synchronized among all nodes as they are used for replication among them.
|
||||
|
||||
@@ -36,18 +36,18 @@ You can find below an overview of steps for converting an existing Postgres clus
|
||||
|
||||
#. If you are running Postgres through systemd, then disable the Postgres systemd unit. This is performed as Patroni manages starting and stopping the Postgres daemon.
|
||||
|
||||
#. Create a YAML configuration file for Patroni.
|
||||
#. Create a YAML configuration file for Patroni. You can use :ref:`Patroni configuration generation and validation tooling <validate_generate_config>` for that.
|
||||
|
||||
* **Note (specific for the primary node):** If you have replication slots being used for replication between cluster members, then it is recommended that you enable ``use_slots`` and configure the existing replication slots as permanent via the ``slots`` configuration item. Be aware that Patroni automatically creates replication slots for replication between members, and drops replication slots that it does not recognize, when ``use_slots`` is enabled. The idea of using permanent slots here is to allow your existing slots to persist while the migration to Patroni is in progress. See :ref:`YAML Configuration Settings <yaml_configuration>` for details.
|
||||
|
||||
#. Start Patroni using the ``patroni`` systemd service unit. It automatically detects that Postgres is already running and starts monitoring the instance.
|
||||
|
||||
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through ``patronictl restart cluster-name member-name`` command. For minimal downtime you might want to split this step into:
|
||||
#. Hand over Postgres "start up procedure" to Patroni. In order to do that you need to restart the cluster members through :ref:`patronictl restart cluster-name member-name <patronictl_restart_parameters>` command. For minimal downtime you might want to split this step into:
|
||||
|
||||
#. Immediate restart of the standby nodes.
|
||||
#. Scheduled restart of the primary node within a maintenance window.
|
||||
|
||||
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through ``patronictl edit-config cluster-name member-name`` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
|
||||
#. If you configured permanent slots in step ``1.2.``, then you should remove them from ``slots`` configuration through :ref:`patronictl edit-config cluster-name member-name <patronictl_edit_config_parameters>` command once the ``restart_lsn`` of the slots created by Patroni is able to catch up with the ``restart_lsn`` of the original slots for the corresponding members. By removing the slots from ``slots`` configuration you will allow Patroni to drop the original slots from your cluster once they are not needed anymore. You can find below an example query to check the ``restart_lsn`` of a couple slots, so you can compare them:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
@@ -73,7 +73,7 @@ The only possible way to do a major upgrade currently is:
|
||||
#. Stop Patroni
|
||||
#. Upgrade PostgreSQL binaries and perform `pg_upgrade <https://www.postgresql.org/docs/current/pgupgrade.html>`_ on the primary node
|
||||
#. Update patroni.yml
|
||||
#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running ``patronictl remove <cluster-name>``. It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
|
||||
#. Remove the initialize key from DCS or wipe complete cluster state from DCS. The second one could be achieved by running :ref:`patronictl remove cluster-name <patronictl_remove_parameters>` . It is necessary because pg_upgrade runs initdb which actually creates a new database with a new PostgreSQL system identifier.
|
||||
#. If you wiped the cluster state in the previous step, you may wish to copy patroni.dynamic.json from old data dir to the new one. It will help you to retain some PostgreSQL parameters you had set before.
|
||||
#. Start Patroni on the primary node.
|
||||
#. Upgrade PostgreSQL binaries, update patroni.yml and wipe the data_dir on standby nodes.
|
||||
|
||||
+2
-1
@@ -10,7 +10,7 @@ Patroni is a template for high availability (HA) PostgreSQL solutions using Pyth
|
||||
|
||||
We call Patroni a "template" because it is far from being a one-size-fits-all or plug-and-play replication system. It will have its own caveats. Use wisely. There are many ways to run high availability with PostgreSQL; for a list, see the `PostgreSQL Documentation <https://wiki.postgresql.org/wiki/Replication,_Clustering,_and_Connection_Pooling>`__.
|
||||
|
||||
Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
Currently supported PostgreSQL versions: 9.3 to 16.
|
||||
|
||||
**Note to Citus users**: Starting from 3.0 Patroni nicely integrates with the `Citus <https://github.com/citusdata/citus>`__ database extension to Postgres. Please check the :ref:`Citus support page <citus>` in the Patroni documentation for more info about how to use Patroni high availability together with a Citus distributed cluster.
|
||||
|
||||
@@ -25,6 +25,7 @@ Currently supported PostgreSQL versions: 9.3 to 15.
|
||||
installation
|
||||
patroni_configuration
|
||||
rest_api
|
||||
patronictl
|
||||
replica_bootstrap
|
||||
replication_modes
|
||||
watchdog
|
||||
|
||||
+12
-17
@@ -30,23 +30,10 @@ There are a few options available:
|
||||
sudo apt-get install python3-psycopg2 # install psycopg2 module on Debian/Ubuntu
|
||||
sudo yum install python3-psycopg2 # install psycopg2 on RedHat/Fedora/CentOS
|
||||
|
||||
2. Install psycopg2 from the binary package
|
||||
2. Specify one of `psycopg`, `psycopg2`, or `psycopg2-binary` in the :ref:`list of dependencies <extras>` when installing Patroni with pip.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install psycopg2-binary
|
||||
|
||||
3. Install psycopg2 from source
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install psycopg2>=2.5.4
|
||||
|
||||
4. Use psycopg 3.0 instead of psycopg2
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install psycopg[binary]>=3.0.0
|
||||
.. _extras:
|
||||
|
||||
General installation for pip
|
||||
----------------------------
|
||||
@@ -73,12 +60,20 @@ raft
|
||||
`pysyncobj` module in order to use python Raft implementation as DCS
|
||||
aws
|
||||
`boto3` in order to use AWS callbacks
|
||||
all
|
||||
all of the above (except psycopg family)
|
||||
psycopg
|
||||
`psycopg[binary]>=3.0.0` module
|
||||
psycopg2
|
||||
`psycopg2>=2.5.4` module
|
||||
psycopg2-binary
|
||||
`psycopg2-binary` module
|
||||
|
||||
For example, the command in order to install Patroni together with dependencies for Etcd as a DCS and AWS callbacks is:
|
||||
For example, the command in order to install Patroni together with psycopg3, dependencies for Etcd as a DCS, and AWS callbacks is:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install patroni[etcd,aws]
|
||||
pip install patroni[psycopg3,etcd3,aws]
|
||||
|
||||
Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed
|
||||
independently of Patroni.
|
||||
|
||||
+118
-13
@@ -15,7 +15,7 @@ There are 3 types of Patroni configuration:
|
||||
|
||||
- Global :ref:`dynamic configuration <dynamic_configuration>`.
|
||||
These options are stored in the DCS (Distributed Configuration Store) and applied on all cluster nodes.
|
||||
Dynamic configuration can be set at any time using ``patronictl edit-config`` tool or Patroni :ref:`REST API <rest_api>`.
|
||||
Dynamic configuration can be set at any time using :ref:`patronictl_edit_config` tool or Patroni :ref:`REST API <rest_api>`.
|
||||
If the options changed are not part of the startup configuration, they are applied asynchronously (upon the next wake up cycle)
|
||||
to every node, which gets subsequently reloaded.
|
||||
If the node requires a restart to apply the configuration (for `PostgreSQL parameters <https://www.postgresql.org/docs/current/view-pg-settings.html>`__ with context postmaster, if their values
|
||||
@@ -24,7 +24,7 @@ There are 3 types of Patroni configuration:
|
||||
|
||||
- Local :ref:`configuration file <yaml_configuration>` (patroni.yml).
|
||||
These options are defined in the configuration file and take precedence over dynamic configuration.
|
||||
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing ``patronictl reload``. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
|
||||
``patroni.yml`` can be changed and reloaded at runtime (without restart of Patroni) by sending SIGHUP to the Patroni process, performing ``POST /reload`` REST-API request or executing :ref:`patronictl_reload`. Local configuration can be either a single YAML file or a directory. When it is a directory, all YAML files in that directory are loaded one by one in sorted order. In case a key is defined in multiple files, the occurrence in the last file takes precedence.
|
||||
|
||||
- :ref:`Environment configuration <environment>`.
|
||||
It is possible to set/override some of the "Local" configuration parameters with environment variables.
|
||||
@@ -70,15 +70,16 @@ There also are some parameters like **postgresql.listen**, **postgresql.data_dir
|
||||
|
||||
When applying the local or dynamic configuration options, the following actions are taken:
|
||||
|
||||
- The node first checks if there is a `postgresql.base.conf` or if the ``custom_conf`` parameter is set.
|
||||
- If the ``custom_conf`` parameter is set, it will take the file specified on it as a base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
|
||||
- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and it will be used as a base configuration.
|
||||
- If there is no ``custom_conf``` nor `postgresql.base.conf`, the original `postgresql.conf`` is taken and renamed to postgresql.base.conf.
|
||||
- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf`` and an include is set in
|
||||
postgresql.conf to the used base configuration (either `postgresql.base.conf` or what is on ``custom_conf``). Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present not.
|
||||
- The node first checks if there is a `postgresql.base.conf` file or if the ``custom_conf`` parameter is set.
|
||||
- If the ``custom_conf`` parameter is set, the file it specifies is used as the base configuration, ignoring `postgresql.base.conf` and `postgresql.conf`.
|
||||
- If the ``custom_conf`` parameter is not set and `postgresql.base.conf` exists, it contains the renamed "original" configuration and is used as the base configuration.
|
||||
- If there is no ``custom_conf`` nor `postgresql.base.conf`, the original `postgresql.conf` is renamed to `postgresql.base.conf` and used as the base configuration.
|
||||
- The dynamic options (with the exceptions above) are dumped into the `postgresql.conf` and an include is set in
|
||||
`postgresql.conf` to the base configuration (either `postgresql.base.conf` or the file at ``custom_conf``).
|
||||
Therefore, we would be able to apply new options without re-reading the configuration file to check if the include is present or not.
|
||||
- Some parameters that are essential for Patroni to manage the cluster are overridden using the command line.
|
||||
- If some of the options that require restart are changed (we should look at the context in pg_settings and at the actual
|
||||
values of those options), a pending_restart flag of a given node is set. This flag is reset on any restart.
|
||||
- If an option that requires restart is changed (we should look at the context in pg_settings and at the actual
|
||||
values of those options), a pending_restart flag is set on that node. This flag is reset on any restart.
|
||||
|
||||
The parameters would be applied in the following order (run-time are given the highest priority):
|
||||
|
||||
@@ -105,10 +106,10 @@ Changing these parameters require a PostgreSQL restart to take effect, and their
|
||||
|
||||
As explained before, Patroni restrict changing their values through :ref:`dynamic configuration <dynamic_configuration>`, which usually consists of:
|
||||
|
||||
1. Applying changes through ``patronictl edit-config`` (or via REST API ``/config`` endpoint)
|
||||
2. Restarting nodes through ``patronictl restart`` (or via REST API ``/restart`` endpoint)
|
||||
1. Applying changes through :ref:`patronictl_edit_config` (or via REST API ``/config`` endpoint)
|
||||
2. Restarting nodes through :ref:`patronictl_restart` (or via REST API ``/restart`` endpoint)
|
||||
|
||||
**Note:** please keep in mind that you should perform a restart of the PostgreSQL nodes through ``patronictl restart`` command, or via REST API ``/restart`` endpoint. An attempt to restart PostgreSQL by restarting the Patroni daemon, e.g. by executing ``systemctl restart patroni``, can cause a failover to occur in the cluster, if you are restarting the primary node.
|
||||
**Note:** please keep in mind that you should perform a restart of the PostgreSQL nodes through :ref:`patronictl_restart` command, or via REST API ``/restart`` endpoint. An attempt to restart PostgreSQL by restarting the Patroni daemon, e.g. by executing ``systemctl restart patroni``, can cause a failover to occur in the cluster, if you are restarting the primary node.
|
||||
|
||||
However, as those settings manage shared memory, some extra care should be taken when restarting the nodes:
|
||||
|
||||
@@ -142,3 +143,107 @@ Also the following Patroni configuration options **can be changed only dynamical
|
||||
Upon changing these options, Patroni will read the relevant section of the configuration stored in DCS and change its run-time values.
|
||||
|
||||
Patroni nodes are dumping the state of the DCS options to disk upon for every change of the configuration into the file ``patroni.dynamic.json`` located in the Postgres data directory. Only the leader is allowed to restore these options from the on-disk dump if these are completely absent from the DCS or if they are invalid.
|
||||
|
||||
|
||||
.. _validate_generate_config:
|
||||
|
||||
Configuration generation and validation
|
||||
---------------------------------------
|
||||
|
||||
Patroni provides command-line interfaces for a Patroni :ref:`local configuration <yaml_configuration>` generation and validation. Using the ``patroni`` executable you can:
|
||||
|
||||
- Create a sample local Patroni configuration;
|
||||
- Create a Patroni configuration file for the locally running PostgreSQL instance (e.g. as a preparation step for the :ref:`Patroni integration <existing_data>`);
|
||||
- Validate a given Patroni configuration file.
|
||||
|
||||
|
||||
Sample Patroni configuration
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code:: text
|
||||
|
||||
patroni --generate-sample-config [configfile]
|
||||
|
||||
Description
|
||||
"""""""""""
|
||||
|
||||
Generate a sample Patroni configuration file in ``yaml`` format.
|
||||
Parameter values are defined using the :ref:`Environment configuration <environment>`, otherwise, if not set, the defaults used in Patroni or the ``#FIXME`` string for the values that should be later defined by the user.
|
||||
|
||||
Some default values are defined based on the local setup:
|
||||
|
||||
- **postgresql.listen**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port.
|
||||
- **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``5432`` port.
|
||||
- **postgresql.authentication.rewind**: is only defined if the PostgreSQL version can be defined from the binary and the version is 11 or later.
|
||||
- **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
|
||||
- **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
|
||||
|
||||
Parameters
|
||||
""""""""""
|
||||
|
||||
``configfile`` - full path to the configuration file used to store the result. If not provided, the result is sent to ``stdout``.
|
||||
|
||||
|
||||
Patroni configuration for a running instance
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code:: text
|
||||
|
||||
patroni --generate-config [--dsn DSN] [configfile]
|
||||
|
||||
Description
|
||||
"""""""""""
|
||||
|
||||
Generate a Patroni configuration in ``yaml`` format for the locally running PostgreSQL instance.
|
||||
Either the provided DSN (takes precedence) or PostgreSQL `environment variables <https://www.postgresql.org/docs/current/libpq-envars.html>`__ will be used for the PostgreSQL connection. If the password is not provided, it should be entered via prompt.
|
||||
|
||||
All the non-internal GUCs defined in the source Postgres instance, independently if they were set through a configuration file, through the postmaster command-line, or through environment variables, will be used as the source for the following Patroni configuration parameters:
|
||||
|
||||
- **scope**: ``cluster_name`` GUC value;
|
||||
- **postgresql.listen**: ``listen_addresses`` and ``port`` GUC values;
|
||||
- **postgresql.datadir**: ``data_directory`` GUC value;
|
||||
- **postgresql.parameters**: ``archive_command``, ``restore_command``, ``archive_cleanup_command``, ``recovery_end_command``, ``ssl_passphrase_command``, ``hba_file``, ``ident_file``, ``config_file`` GUC values;
|
||||
- **bootstrap.dcs**: all other gathered PostgreSQL GUCs.
|
||||
|
||||
If ``scope``, ``postgresql.listen`` or ``postgresql.datadir`` is not set from the Postgres GUCs, the respective :ref:`Environment configuration <environment>` value is used.
|
||||
|
||||
Other rules applied for the values definition:
|
||||
|
||||
- **name**: ``PATRONI_NAME`` environment variable value if set, otherwise the current machine's hostname.
|
||||
- **postgresql.bin_dir**: path to the Postgres binaries gathered from the running instance.
|
||||
- **postgresql.connect_address**: the IP address returned by ``gethostname`` call for the current machine's hostname and the port used for the instance connection or the ``port`` GUC value.
|
||||
- **postgresql.authentication.superuser**: the configuration used for the instance connection;
|
||||
- **postgresql.pg_hba**: the lines gathered from the source instance's ``hba_file``.
|
||||
- **postgresql.pg_ident**: the lines gathered from the source instance's ``ident_file``.
|
||||
- **restapi.listen**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
|
||||
- **restapi.connect_address**: IP address returned by ``gethostname`` call for the current machine's hostname and the standard ``8008`` port.
|
||||
|
||||
Other parameters defined using :ref:`Environment configuration <environment>` are also included into the configuration.
|
||||
|
||||
Parameters
|
||||
""""""""""
|
||||
|
||||
``configfile``
|
||||
Full path to the configuration file used to store the result. If not provided, result is sent to ``stdout``.
|
||||
|
||||
``dsn``
|
||||
Optional DSN string for the local PostgreSQL instance to get GUC values from.
|
||||
|
||||
|
||||
Validate Patroni configuration
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. code:: text
|
||||
|
||||
patroni --validate-config [configfile]
|
||||
|
||||
Description
|
||||
"""""""""""
|
||||
|
||||
Validate the given Patroni configuration and print the information about the failed checks.
|
||||
|
||||
Parameters
|
||||
""""""""""
|
||||
|
||||
``configfile``
|
||||
Full path to the configuration file to check. If not given or file does not exist, will try to read from the ``PATRONI_CONFIG_VARIABLE`` environment variable or, if not set, from the :ref:`Patroni environment variables <environment>`.
|
||||
|
||||
+1975
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -32,6 +32,6 @@ When Patroni runs in a paused mode, it does not change the state of PostgreSQL,
|
||||
User guide
|
||||
----------
|
||||
|
||||
``patronictl`` supports ``pause`` and ``resume`` commands.
|
||||
``patronictl`` supports :ref:`pause <patronictl_pause>` and :ref:`resume <patronictl_resume>` commands.
|
||||
|
||||
One can also issue a ``PATCH`` request to the ``{namespace}/{cluster}/config`` key with ``{"pause": true/false/null}``
|
||||
|
||||
@@ -3,6 +3,158 @@
|
||||
Release notes
|
||||
=============
|
||||
|
||||
Version 3.2.0
|
||||
-------------
|
||||
|
||||
**Deprecation notice**
|
||||
|
||||
- The ``bootstrap.users`` support will be removed in version 4.0.0. If you need to create users after deploying a new cluster please use the ``bootstrap.post_bootstrap`` hook for that.
|
||||
|
||||
|
||||
**Breaking changes**
|
||||
|
||||
- Enforce ``loop_wait + 2*retry_timeout <= ttl`` rule and hard-code minimal possible values (Alexander Kukushkin)
|
||||
|
||||
Minimal values: ``loop_wait=2``, ``retry_timeout=3``, ``ttl=20``. In case values are smaller or violate the rule they are adjusted and a warning is written to Patroni logs.
|
||||
|
||||
|
||||
**New features**
|
||||
|
||||
- Failover priority (Mark Pekala)
|
||||
|
||||
With the help of ``tags.failover_priority`` it's now possible to make a node more preferred during the leader race. More details in the documentation (ref tags).
|
||||
|
||||
- Implemented ``patroni --generate-config [--dsn DSN]`` and ``patroni --generate-sample-config`` (Polina Bungina)
|
||||
|
||||
It allows to generate a config file for the running PostgreSQL cluster or a sample config file for the new Patroni cluster.
|
||||
|
||||
- Use a dedicated connection to Postgres for Patroni REST API (Alexander Kukushkin)
|
||||
|
||||
It helps to avoid blocking the main heartbeat loop if the system is under stress.
|
||||
|
||||
- Enrich some endpoints with the ``name`` of the node (sskserk)
|
||||
|
||||
For the monitoring endpoint ``name`` is added next to the ``scope`` and for metrics endpoint the ``name`` is added to tags.
|
||||
|
||||
- Ensure strict failover/switchover difference (Polina Bungina)
|
||||
|
||||
Be more precise in log messages and allow failing over to an asynchronous node in a healthy synchronous cluster.
|
||||
|
||||
- Make permanent physical replication slots behave similarly to permanent logical slots (Alexander Kukushkin)
|
||||
|
||||
Create permanent physical replication slots on all nodes that are allowed to become the leader and use ``pg_replication_slot_advance()`` function to advance ``restart_lsn`` for slots on standby nodes.
|
||||
|
||||
- Add capability of specifying namespace through ``--dcs`` argument in ``patronictl`` (Israel Barth Rubio)
|
||||
|
||||
It could be handy if ``patronictl`` is used without a configuration file.
|
||||
|
||||
- Add support for additional parameters in custom bootstrap configuration (Israel Barth Rubio)
|
||||
|
||||
Previously it was only possible to add custom arguments to the ``command`` and now one could list them as a mapping.
|
||||
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Set ``citus.local_hostname`` GUC to the same value which is used by Patroni to connect to the Postgres (Alexander Kukushkin)
|
||||
|
||||
There are cases when Citus wants to have a connection to the local Postgres. By default it uses ``localhost``, which is not always available.
|
||||
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Ignore ``synchronous_mode`` setting in a standby cluster (Polina Bungina)
|
||||
|
||||
Postgres doesn't support cascading synchronous replication and not ignoring ``synchronous_mode`` was breaking a switchover in a standby cluster.
|
||||
|
||||
- Handle SIGCHLD for ``on_reload`` callback (Alexander Kukushkin)
|
||||
|
||||
Not doing so results in a zombie process, which is reaped only when the next ``on_reload`` is executed.
|
||||
|
||||
- Handle ``AuthOldRevision`` error when working with Etcd v3 (Alexander Kukushkin, Kenny Do)
|
||||
|
||||
The error is raised if Etcd is configured to use JWT and when the user database in Etcd is updated.
|
||||
|
||||
|
||||
Version 3.1.2
|
||||
-------------
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Fixed bug with ``wal_keep_size`` checks (Alexander Kukushkin)
|
||||
|
||||
The ``wal_keep_size`` is a GUC that normally has a unit and Patroni was failing to cast its value to ``int``. As a result the value of ``bootstrap.dcs`` was not written to the ``/config`` key afterwards.
|
||||
|
||||
- Detect and resolve inconsistencies between ``/sync`` key and ``synchronous_standby_names`` (Alexander Kukushkin)
|
||||
|
||||
Normally, Patroni updates ``/sync`` and ``synchronous_standby_names`` in a very specific order, but in case of a bug or when someone manually reset ``synchronous_standby_names``, Patroni was getting into an inconsistent state. As a result it was possible that the failover happens to an asynchronous node.
|
||||
|
||||
- Read GUC's values when joining running Postgres (Alexander Kukushkin)
|
||||
|
||||
When restarted in ``pause``, Patroni was discarding the ``synchronous_standby_names`` GUC from the ``postgresql.conf``. To solve it and avoid similar issues, Patroni will read GUC's value if it is joining an already running Postgres.
|
||||
|
||||
- Silenced annoying warnings when checking for node uniqueness (Alexander Kukushkin)
|
||||
|
||||
``WARNING`` messages are produced by ``urllib3`` if Patroni is quickly restarted.
|
||||
|
||||
|
||||
Version 3.1.1
|
||||
-------------
|
||||
|
||||
**Bugfixes**
|
||||
|
||||
- Reset failsafe state on promote (ChenChangAo)
|
||||
|
||||
If switchover/failover happened shortly after failsafe mode had been activated, the newly promoted primary was demoting itself after failsafe becomes inactive.
|
||||
|
||||
- Silence useless warnings in ``patronictl`` (Alexander Kukushkin)
|
||||
|
||||
If ``patronictl`` uses the same patroni.yaml file as Patroni and can access ``PGDATA`` directory it might have been showing annoying warnings about incorrect values in the global configuration.
|
||||
|
||||
- Explicitly enable synchronous mode for a corner case (Alexander Kukushkin)
|
||||
|
||||
Synchronous mode effectively was never activated if there are no replicas streaming from the primary.
|
||||
|
||||
- Fixed bug with ``0`` integer values validation (Israel Barth Rubio)
|
||||
|
||||
In most cases, it didn't cause any issues, just warnings.
|
||||
|
||||
- Don't return logical slots for standby cluster (Alexander Kukushkin)
|
||||
|
||||
Patroni can't create logical replication slots in the standby cluster, thus they should be ignored if they are defined in the global configuration.
|
||||
|
||||
- Avoid showing docstring in ``patronictl --help`` output (Israel Barth Rubio)
|
||||
|
||||
The ``click`` module needs to get a special hint for that.
|
||||
|
||||
- Fixed bug with ``kubernetes.standby_leader_label_value`` (Alexander Kukushkin)
|
||||
|
||||
This feature effectively never worked.
|
||||
|
||||
- Returned cluster system identifier to the ``patronictl list`` output (Polina Bungina)
|
||||
|
||||
The problem was introduced while implementing the support for Citus, where we need to hide the identifier because it is different for coordinator and all workers.
|
||||
|
||||
- Override ``write_leader_optime`` method in Kubernetes implementation (Alexander Kukushkin)
|
||||
|
||||
The method is supposed to write shutdown LSN to the leader Endpoint/ConfigMap when there are no healthy replicas available to become the new primary.
|
||||
|
||||
- Don't start stopped postgres in pause (Alexander Kukushkin)
|
||||
|
||||
Due to a race condition, Patroni was falsely assuming that the standby should be restarted because some recovery parameters (``primary_conninfo`` or similar) were changed.
|
||||
|
||||
- Fixed bug in ``patronictl query`` command (Israel Barth Rubio)
|
||||
|
||||
It didn't work when only ``-m`` argument was provided or when none of ``-r`` or ``-m`` were provided.
|
||||
|
||||
- Properly treat integer parameters that are used in the command line to start postgres (Polina Bungina)
|
||||
|
||||
If values are supplied as strings and not casted to integer it was resulting in an incorrect calculation of ``max_prepared_transactions`` based on ``max_connections`` for Citus clusters.
|
||||
|
||||
- Don't rely on ``pg_stat_wal_receiver`` when deciding on ``pg_rewind`` (Alexander Kukushkin)
|
||||
|
||||
It could happen that ``received_tli`` reported by ``pg_stat_wal_recevier`` is ahead of the actual replayed timeline, while the timeline reported by ``DENTIFY_SYSTEM`` via replication connection is always correct.
|
||||
|
||||
|
||||
Version 3.1.0
|
||||
-------------
|
||||
|
||||
|
||||
@@ -43,16 +43,31 @@ in the configuration files, Patroni supplies two cluster-specific ones:
|
||||
|
||||
Passing these two additional flags can be disabled by setting a special ``no_params`` parameter to ``True``.
|
||||
|
||||
If the bootstrap script returns 0, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
|
||||
If the bootstrap script returns ``0``, Patroni tries to configure and start the PostgreSQL instance produced by it. If any
|
||||
of the intermediate steps fail, or the script returns a non-zero value, Patroni assumes that the bootstrap has failed,
|
||||
cleans up after itself and releases the initialize lock to give another node the opportunity to bootstrap.
|
||||
|
||||
If a ``recovery_conf`` block is defined in the same section as the custom bootstrap method, Patroni will generate a
|
||||
``recovery.conf`` before starting the newly bootstrapped instance. Typically, such recovery.conf should contain at least
|
||||
one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
|
||||
``recovery.conf`` before starting the newly bootstrapped instance (or set the recovery settings on Postgres configuration if
|
||||
running PostgreSQL >= 12).
|
||||
Typically, such recovery configuration should contain at least one of the ``recovery_target_*`` parameters, together with the ``recovery_target_timeline`` set to ``promote``.
|
||||
|
||||
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists.
|
||||
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate ``recovery.conf`` for you.
|
||||
If ``keep_existing_recovery_conf`` is defined and set to ``True``, Patroni will not remove the existing ``recovery.conf`` file if it exists (PostgreSQL <= 11).
|
||||
Similarly, in that case Patroni will not remove the existing ``recovery.signal`` or ``standby.signal`` if either exists, nor will it override the configured recovery settings (PostgreSQL >= 12).
|
||||
This is useful when bootstrapping from a backup with tools like pgBackRest that generate the appropriate recovery configuration for you.
|
||||
|
||||
Besides that, any additional key/value pairs informed in the custom bootstrap method configuration will be passed as arguments to ``command`` in the format ``--name=value``. For example:
|
||||
|
||||
.. code:: YAML
|
||||
|
||||
bootstrap:
|
||||
method: <custom_bootstrap_method_name>
|
||||
<custom_bootstrap_method_name>:
|
||||
command: <path_to_custom_bootstrap_script>
|
||||
arg1: value1
|
||||
arg2: value2
|
||||
|
||||
Makes the configured ``command`` to be called additionally with ``--arg1=value1 --arg2=value2`` command-line arguments.
|
||||
|
||||
.. note:: Bootstrap methods are neither chained, nor fallen-back to the default one in case the primary one fails
|
||||
|
||||
@@ -191,7 +206,7 @@ There is no further relationship between the standby cluster and the primary
|
||||
cluster it replicates from, in particular, they must not share the same DCS
|
||||
scope if they use the same DCS. They do not know anything else from each other
|
||||
apart from replication information. Also, the standby cluster is not being
|
||||
displayed in ``patronictl list`` or ``patronictl topology`` output on the
|
||||
displayed in :ref:`patronictl_list` or :ref:`patronictl_topology` output on the
|
||||
primary cluster.
|
||||
|
||||
For the sake of flexibility, you can specify methods of creating a replica and
|
||||
|
||||
+7
-7
@@ -3,7 +3,7 @@
|
||||
Patroni REST API
|
||||
================
|
||||
|
||||
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
|
||||
Patroni has a rich REST API, which is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring. Below you will find the list of Patroni REST API endpoints.
|
||||
|
||||
Health check endpoints
|
||||
----------------------
|
||||
@@ -619,9 +619,9 @@ In the JSON body of the ``POST`` request you must specify the ``candidate`` fiel
|
||||
:ref:`Be very careful <failover_healthcheck>` when using this endpoint, as this can cause data loss in certain situations. In most cases, :ref:`the switchover endpoint <switchover_api>` satisfies the administrator's needs.
|
||||
|
||||
|
||||
``POST /switchover`` and ``POST /failover`` endpoints are used by ``patronictl switchover`` and ``patronictl failover``, respectively.
|
||||
``POST /switchover`` and ``POST /failover`` endpoints are used by :ref:`patronictl_switchover` and :ref:`patronictl_failover`, respectively.
|
||||
|
||||
``DELETE /switchover`` is used by ``patronictl flush <cluster-name> switchover``.
|
||||
``DELETE /switchover`` is used by :ref:`patronictl flush cluster-name switchover <patronictl_flush_parameters>`.
|
||||
|
||||
.. list-table:: Failover/Switchover comparison
|
||||
:widths: 25 25 25
|
||||
@@ -680,15 +680,15 @@ Restart endpoint
|
||||
|
||||
- ``DELETE /restart``: delete the scheduled restart
|
||||
|
||||
``POST /restart`` and ``DELETE /restart`` endpoints are used by ``patronictl restart`` and ``patronictl flush <cluster-name> restart`` respectively.
|
||||
``POST /restart`` and ``DELETE /restart`` endpoints are used by :ref:`patronictl_restart` and :ref:`patronictl flush cluster-name restart <patronictl_flush_parameters>` respectively.
|
||||
|
||||
|
||||
Reload endpoint
|
||||
---------------
|
||||
|
||||
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of ``patronictl restart``.
|
||||
The ``POST /reload`` call will order Patroni to re-read and apply the configuration file. This is the equivalent of sending the ``SIGHUP`` signal to the Patroni process. In case you changed some of the Postgres parameters which require a restart (like **shared_buffers**), you still have to explicitly do the restart of Postgres by either calling the ``POST /restart`` endpoint or with the help of :ref:`patronictl_restart`.
|
||||
|
||||
The reload endpoint is used by ``patronictl reload``.
|
||||
The reload endpoint is used by :ref:`patronictl_reload`.
|
||||
|
||||
|
||||
Reinitialize endpoint
|
||||
@@ -698,4 +698,4 @@ Reinitialize endpoint
|
||||
|
||||
The call might fail if Patroni is in a loop trying to recover (restart) a failed Postgres. In order to overcome this problem one can specify ``{"force":true}`` in the request body.
|
||||
|
||||
The reinitialize endpoint is used by ``patronictl reinit``.
|
||||
The reinitialize endpoint is used by :ref:`patronictl_reinit`.
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ A Patroni cluster has two interfaces to be protected from unauthorized access: t
|
||||
Protecting DCS
|
||||
==============
|
||||
|
||||
Patroni and patronictl both store and retrieve data to/from the DCS.
|
||||
Patroni and :ref:`patronictl` both store and retrieve data to/from the DCS.
|
||||
|
||||
Despite DCS doesn't contain any sensitive information, it allows changing some of Patroni/Postgres configuration. Therefore the very first thing that should be protected is DCS itself.
|
||||
|
||||
@@ -22,7 +22,7 @@ Protecting the REST API
|
||||
|
||||
Protecting the REST API is a more complicated task.
|
||||
|
||||
The Patroni REST API is used by Patroni itself during the leader race, by the ``patronictl`` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
|
||||
The Patroni REST API is used by Patroni itself during the leader race, by the :ref:`patronictl` tool in order to perform failovers/switchovers/reinitialize/restarts/reloads, by HAProxy or any other kind of load balancer to perform HTTP health checks, and of course could also be used for monitoring.
|
||||
|
||||
From the point of view of security, REST API contains safe (``GET`` requests, only retrieve information) and unsafe (``PUT``, ``POST``, ``PATCH`` and ``DELETE`` requests, change the state of nodes) endpoints.
|
||||
|
||||
@@ -32,6 +32,6 @@ When TLS for the REST API is enabled and a PKI is established, mutual authentica
|
||||
|
||||
The ``restapi`` section parameters enable TLS client authentication to the server. Depending on the value of the ``verify_client`` parameter, the API server requires a successful client certificate verification for both safe and unsafe API calls (``verify_client: required``), or only for unsafe API calls (``verify_client: optional``), or for no API calls (``verify_client: none``).
|
||||
|
||||
The ``ctl`` section parameters enable TLS server authentication to the client (the ``patronictl`` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings <patronictl_settings>` for a detailed description of the TLS client parameters.
|
||||
The ``ctl`` section parameters enable TLS server authentication to the client (the :ref:`patronictl` tool which uses the same config as patroni). Set ``insecure: true`` to disable the server certificate verification by the client. See :ref:`settings <patronictl_settings>` for a detailed description of the TLS client parameters.
|
||||
|
||||
Protecting the PostgreSQL database proper from unauthorized access is beyond the scope of this document and is covered in https://www.postgresql.org/docs/current/client-authentication.html
|
||||
|
||||
+10
-22
@@ -34,7 +34,7 @@ Bootstrap configuration
|
||||
.. note::
|
||||
Once Patroni has initialized the cluster for the first time and settings have been stored in the DCS, all future
|
||||
changes to the ``bootstrap.dcs`` section of the YAML configuration will not take any effect! If you want to change
|
||||
them please use either ``patronictl edit-config`` or the Patroni :ref:`REST API <rest_api>`.
|
||||
them please use either :ref:`patronictl_edit_config` or the Patroni :ref:`REST API <rest_api>`.
|
||||
|
||||
- **bootstrap**:
|
||||
|
||||
@@ -49,24 +49,8 @@ Bootstrap configuration
|
||||
- **- data-checksums**: Must be enabled when pg_rewind is needed on 9.3.
|
||||
- **- encoding: UTF8**: default encoding for new databases.
|
||||
- **- locale: UTF8**: default locale for new databases.
|
||||
- **users**: Some additional users which need to be created after initializing new cluster, see :ref:`Bootstrap users configuration <bootstrap_users_configuration>` below.
|
||||
- **post\_bootstrap** or **post\_init**: An additional script that will be executed after initializing the cluster. The script receives a connection string URL (with the cluster superuser as a user name). The PGPASSFILE variable is set to the location of pgpass file.
|
||||
|
||||
.. _bootstrap_users_configuration:
|
||||
|
||||
Bootstrap users configuration
|
||||
=============================
|
||||
|
||||
Users which need to be created after initializing the cluster:
|
||||
|
||||
- **admin**: the name of user
|
||||
|
||||
- **password**: (optional) password for the user
|
||||
- **options**: list of options for CREATE USER statement
|
||||
|
||||
- **- createrole**
|
||||
- **- createdb**
|
||||
|
||||
.. _citus_settings:
|
||||
|
||||
Citus
|
||||
@@ -366,10 +350,10 @@ CTL
|
||||
|
||||
- **authentication**:
|
||||
|
||||
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "username" parameter.
|
||||
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided patronictl will use the value provided for REST API "password" parameter.
|
||||
- **username**: Basic-auth username for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "username" parameter.
|
||||
- **password**: Basic-auth password for accessing protected REST API endpoints. If not provided :ref:`patronictl` will use the value provided for REST API "password" parameter.
|
||||
- **insecure**: Allow connections to REST API without verifying SSL certs.
|
||||
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided patronictl will use the value provided for REST API "cafile" parameter.
|
||||
- **cacert**: Specifies the file with the CA_BUNDLE file or directory with certificates of trusted CAs to use while verifying REST API SSL certs. If not provided :ref:`patronictl` will use the value provided for REST API "cafile" parameter.
|
||||
- **certfile**: Specifies the file with the client certificate in the PEM format.
|
||||
- **keyfile**: Specifies the file with the client secret key in the PEM format.
|
||||
- **keyfile\_password**: Specifies a password for decrypting the client keyfile.
|
||||
@@ -384,11 +368,15 @@ Watchdog
|
||||
|
||||
Tags
|
||||
----
|
||||
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
|
||||
- **clonefrom**: ``true`` or ``false``. If set to ``true`` other nodes might prefer to use this node for bootstrap (take ``pg_basebackup`` from). If there are several nodes with ``clonefrom`` tag set to ``true`` the node to bootstrap from will be chosen randomly. The default value is ``false``.
|
||||
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
|
||||
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
|
||||
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
|
||||
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``, meaning this node _can_ participate in leader races.
|
||||
- **failover_priority**: integer, controls the priority that this node should have during failover. Nodes with higher priority will be preferred over lower priority nodes if they received/replayed the same amount of WAL. However, nodes with higher values of receive/replay LSN are preferred regardless of their priority. If the ``failover_priority`` is 0 or negative - such node is not allowed to participate in the leader race and to become a leader (similar to ``nofailover: true``).
|
||||
|
||||
.. warning::
|
||||
Provide only one of ``nofailover`` or ``failover_priority``. Providing ``nofailover: true`` is the same as ``failover_priority: 0``, and providing ``nofailover: false`` will give the node priority 1.
|
||||
|
||||
In addition to these predefined tags, you can also add your own ones:
|
||||
|
||||
@@ -397,4 +385,4 @@ In addition to these predefined tags, you can also add your own ones:
|
||||
- **key3**: ``1.4``
|
||||
- **key4**: ``"RandomString"``
|
||||
|
||||
Tags are visible in the :ref:`REST API <rest_api>` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
Tags are visible in the :ref:`REST API <rest_api>` and :ref:`patronictl_list` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
|
||||
|
||||
@@ -6,6 +6,7 @@ if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--datadir", required=True)
|
||||
parser.add_argument("--sourcedir", required=True)
|
||||
parser.add_argument("--test-argument", required=True)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
shutil.copytree(args.sourcedir, args.datadir)
|
||||
|
||||
@@ -82,4 +82,4 @@ Feature: basic replication
|
||||
@reject-duplicate-name
|
||||
Scenario: check graceful rejection when two nodes have the same name
|
||||
Given I start duplicate postgres0 on port 8011
|
||||
Then there is a "Can't start; there is already a node named 'postgres0' running" CRITICAL in the dup-postgres0 patroni log
|
||||
Then there is one of ["Can't start; there is already a node named 'postgres0' running"] CRITICAL in the dup-postgres0 patroni log after 5 seconds
|
||||
|
||||
@@ -68,6 +68,6 @@ Feature: citus
|
||||
And I receive a response output "+ttl: 20"
|
||||
Then postgres4 is registered in the postgres2 as the primary in group 2 after 5 seconds
|
||||
When I shut down postgres4
|
||||
Then There is a transaction in progress on postgres0 changing pg_dist_node
|
||||
Then there is a transaction in progress on postgres0 changing pg_dist_node after 5 seconds
|
||||
When I run patronictl.py restart batman postgres2 --group 1 --force
|
||||
Then a transaction finishes in 20 seconds
|
||||
|
||||
@@ -4,14 +4,14 @@ Feature: dcs failsafe mode
|
||||
Scenario: check failsafe mode can be successfully enabled
|
||||
Given I start postgres0
|
||||
And postgres0 is a leader after 10 seconds
|
||||
And I sleep for 3 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 5, "failsafe_mode": true}
|
||||
Then "config" key in DCS has ttl=30 after 10 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"loop_wait": 2, "ttl": 20, "retry_timeout": 3, "failsafe_mode": true}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/failsafe contains postgres0 after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8008/failsafe
|
||||
Then I receive a response code 200
|
||||
And I receive a response postgres0 http://127.0.0.1:8008/patroni
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}}}
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"wal_level": "logical"}},"slots":{"dcs_slot_1": null,"postgres0":null}}
|
||||
Then I receive a response code 200
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots": {"dcs_slot_0": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
|
||||
Then I receive a response code 200
|
||||
@@ -28,7 +28,6 @@ Feature: dcs failsafe mode
|
||||
When I do a backup of postgres0
|
||||
And I shut down postgres0
|
||||
When I start postgres1 in a cluster batman from backup with no_leader
|
||||
And I sleep for 2 seconds
|
||||
Then postgres1 role is the replica after 12 seconds
|
||||
|
||||
Scenario: check leader and replica are both in /failsafe key after leader is back
|
||||
@@ -45,41 +44,73 @@ Feature: dcs failsafe mode
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
Scenario: check leader and replica are functioning while DCS is down
|
||||
Given logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
Given I get all changes from physical slot dcs_slot_1 on postgres0
|
||||
Then physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And DCS is down
|
||||
Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds
|
||||
Then postgres0 role is the primary after 10 seconds
|
||||
And postgres1 role is the replica after 2 seconds
|
||||
And replication works from postgres0 to postgres1 after 10 seconds
|
||||
And I get all changes from logical slot dcs_slot_0 on postgres0
|
||||
And logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds
|
||||
When I get all changes from logical slot dcs_slot_0 on postgres0
|
||||
And I get all changes from physical slot dcs_slot_1 on postgres0
|
||||
Then logical slot dcs_slot_0 is in sync between postgres0 and postgres1 after 20 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres0 and postgres1 after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check primary is demoted when one replica is shut down and DCS is down
|
||||
Given DCS is down
|
||||
And I kill postgres1
|
||||
And I kill postmaster on postgres1
|
||||
And I sleep for 2 seconds
|
||||
Then postgres0 role is the replica after 12 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check known replica is promoted when leader is down and DCS is up
|
||||
Given I shut down postgres0
|
||||
Given I kill postgres0
|
||||
And I shut down postmaster on postgres0
|
||||
And DCS is up
|
||||
When I start postgres1
|
||||
Then "members/postgres1" key in DCS has state=running after 10 seconds
|
||||
And postgres1 role is the primary after 25 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check three-node cluster is functioning while DCS is down
|
||||
Scenario: scale to three-node cluster
|
||||
Given I start postgres0
|
||||
And I start postgres2
|
||||
Then "members/postgres2" key in DCS has state=running after 10 seconds
|
||||
And "members/postgres0" key in DCS has state=running after 20 seconds
|
||||
And Response on GET http://127.0.0.1:8008/failsafe contains postgres2 after 10 seconds
|
||||
And replication works from postgres1 to postgres0 after 10 seconds
|
||||
And replication works from postgres1 to postgres2 after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
Scenario: make sure permanent slots exist on replicas
|
||||
Given I issue a PATCH request to http://127.0.0.1:8009/config with {"slots":{"dcs_slot_0":null,"dcs_slot_2":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
|
||||
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
|
||||
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
|
||||
When I get all changes from physical slot dcs_slot_1 on postgres1
|
||||
Then physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
Scenario: check three-node cluster is functioning while DCS is down
|
||||
Given DCS is down
|
||||
Then Response on GET http://127.0.0.1:8008/primary contains failsafe_mode_is_active after 12 seconds
|
||||
Then Response on GET http://127.0.0.1:8009/primary contains failsafe_mode_is_active after 12 seconds
|
||||
Then postgres1 role is the primary after 10 seconds
|
||||
And postgres0 role is the replica after 2 seconds
|
||||
And postgres2 role is the replica after 2 seconds
|
||||
|
||||
@dcs-failsafe
|
||||
@slot-advance
|
||||
Scenario: check that permanent slots are in sync between nodes while DCS is down
|
||||
Given replication works from postgres1 to postgres0 after 10 seconds
|
||||
And replication works from postgres1 to postgres2 after 10 seconds
|
||||
When I get all changes from logical slot dcs_slot_2 on postgres1
|
||||
And I get all changes from physical slot dcs_slot_1 on postgres1
|
||||
Then logical slot dcs_slot_2 is in sync between postgres1 and postgres0 after 20 seconds
|
||||
And logical slot dcs_slot_2 is in sync between postgres1 and postgres2 after 20 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres0 after 10 seconds
|
||||
And physical slot dcs_slot_1 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
And physical slot postgres0 is in sync between postgres1 and postgres2 after 10 seconds
|
||||
|
||||
+23
-15
@@ -162,9 +162,10 @@ class PatroniController(AbstractController):
|
||||
|
||||
def stop(self, kill=False, timeout=15, postgres=False):
|
||||
if postgres:
|
||||
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-mi', '-w'])
|
||||
mode = 'i' if kill else 'f'
|
||||
return subprocess.call(['pg_ctl', '-D', self._data_dir, 'stop', '-m' + mode, '-w'])
|
||||
super(PatroniController, self).stop(kill, timeout)
|
||||
if isinstance(self._context.dcs_ctl, KubernetesController):
|
||||
if isinstance(self._context.dcs_ctl, KubernetesController) and not kill:
|
||||
self._context.dcs_ctl.delete_pod(self._name[8:])
|
||||
if self.watchdog:
|
||||
self.watchdog.stop()
|
||||
@@ -244,6 +245,10 @@ class PatroniController(AbstractController):
|
||||
self.recursive_update(config, custom_config)
|
||||
|
||||
self.recursive_update(config, {
|
||||
'log': {
|
||||
'format': '%(asctime)s %(levelname)s [%(pathname)s:%(lineno)d - %(funcName)s]: %(message)s',
|
||||
'loggers': {'patroni.postgresql.callback_executor': 'DEBUG'}
|
||||
},
|
||||
'bootstrap': {
|
||||
'dcs': {
|
||||
'loop_wait': 2,
|
||||
@@ -649,9 +654,10 @@ class KubernetesController(AbstractExternalDcsController):
|
||||
try:
|
||||
if group is not None:
|
||||
scope = '{0}-{1}'.format(scope, group)
|
||||
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(key, '-' + key)
|
||||
rkey = 'leader' if key in ('status', 'failsafe') else key
|
||||
ep = scope + {'leader': '', 'history': '-config', 'initialize': '-config'}.get(rkey, '-' + rkey)
|
||||
e = self._api.read_namespaced_endpoints(ep, self._namespace)
|
||||
if key != 'sync':
|
||||
if key not in ('sync', 'status', 'failsafe'):
|
||||
return e.metadata.annotations[key]
|
||||
else:
|
||||
return json.dumps(e.metadata.annotations)
|
||||
@@ -687,7 +693,7 @@ class ZooKeeperController(AbstractExternalDcsController):
|
||||
self._client = kazoo.client.KazooClient()
|
||||
|
||||
def process_name(self):
|
||||
return "zookeeper"
|
||||
return "java .*zookeeper"
|
||||
|
||||
def query(self, key, scope='batman', group=None):
|
||||
import kazoo.exceptions
|
||||
@@ -886,22 +892,28 @@ class PatroniPoolController(object):
|
||||
}
|
||||
self.start(to_name, custom_config=custom_config)
|
||||
|
||||
def backup_restore_config(self, params=None):
|
||||
return {
|
||||
'command': (self.BACKUP_RESTORE_SCRIPT
|
||||
+ ' --sourcedir=' + os.path.join(self.patroni_path, 'data', 'basebackup')).replace('\\', '/'),
|
||||
'test-argument': 'test-value', # test config mapping approach on custom bootstrap/replica creation
|
||||
**(params or {}),
|
||||
}
|
||||
|
||||
def bootstrap_from_backup(self, name, cluster_name):
|
||||
custom_config = {
|
||||
'scope': cluster_name,
|
||||
'bootstrap': {
|
||||
'method': 'backup_restore',
|
||||
'backup_restore': {
|
||||
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir='
|
||||
+ os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
|
||||
'backup_restore': self.backup_restore_config({
|
||||
'recovery_conf': {
|
||||
'recovery_target_action': 'promote',
|
||||
'recovery_target_timeline': 'latest',
|
||||
'restore_command': (self.ARCHIVE_RESTORE_SCRIPT + ' --mode restore '
|
||||
+ '--dirname {} --filename %f --pathname %p').format(
|
||||
os.path.join(self.patroni_path, 'data', 'wal_archive_clone').replace('\\', '/'))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
'postgresql': {
|
||||
'authentication': {
|
||||
@@ -922,11 +934,7 @@ class PatroniPoolController(object):
|
||||
.format(os.path.join(self.patroni_path, 'data', 'wal_archive').replace('\\', '/'))
|
||||
},
|
||||
'create_replica_methods': ['no_leader_bootstrap'],
|
||||
'no_leader_bootstrap': {
|
||||
'command': (self.BACKUP_RESTORE_SCRIPT + ' --sourcedir='
|
||||
+ os.path.join(self.patroni_path, 'data', 'basebackup').replace('\\', '/')),
|
||||
'no_leader': '1'
|
||||
}
|
||||
'no_leader_bootstrap': self.backup_restore_config({'no_leader': '1'})
|
||||
}
|
||||
}
|
||||
self.start(name, custom_config=custom_config)
|
||||
|
||||
@@ -25,10 +25,10 @@ Feature: ignored slots
|
||||
# but Patroni can actually end up dropping them almost immediately, so it's helpful
|
||||
# to verify they exist before we begin testing whether they persist through failover
|
||||
# cycles.
|
||||
Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
Then postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
|
||||
When I start postgres0
|
||||
Then "members/postgres0" key in DCS has role=replica after 10 seconds
|
||||
@@ -46,16 +46,16 @@ Feature: ignored slots
|
||||
And "members/postgres1" key in DCS has role=replica after 10 seconds
|
||||
# give Patroni time to sync replication slots
|
||||
And I sleep for 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
And postgres1 does not have a logical replication slot named dummy_slot
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 does not have a replication slot named dummy_slot
|
||||
|
||||
# 3. After a failover the server (now a primary) still has the slot.
|
||||
When I shut down postgres0
|
||||
Then "members/postgres1" key in DCS has role=master after 10 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_0 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_1 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_2 with the test_decoding plugin after 2 seconds
|
||||
And postgres1 has a logical replication slot named unmanaged_slot_3 with the test_decoding plugin after 2 seconds
|
||||
|
||||
@@ -68,6 +68,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
When I kill postmaster on postgres1
|
||||
And I issue a GET request to http://127.0.0.1:8009/replica
|
||||
Then I receive a response code 503
|
||||
And "members/postgres1" key in DCS has state=stopped after 10 seconds
|
||||
When I run patronictl.py restart batman postgres1 --force
|
||||
Then I receive a response returncode 0
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
@@ -76,7 +77,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
|
||||
Then I receive a response code 200
|
||||
And I receive a response state running
|
||||
And I receive a response role replica
|
||||
When I run patronictl.py reinit batman postgres1 --force
|
||||
When I run patronictl.py reinit batman postgres1 --force --wait
|
||||
Then I receive a response returncode 0
|
||||
And I receive a response output "Success: reinitialize for member postgres1"
|
||||
And postgres1 role is the secondary after 30 seconds
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
Feature: permanent slots
|
||||
Scenario: check that physical permanent slots are created
|
||||
Given I start postgres0
|
||||
Then postgres0 is a leader after 10 seconds
|
||||
And there is a non empty initialize key in DCS after 15 seconds
|
||||
When I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_physical":0,"postgres0":0,"postgres1":0,"postgres3":0},"postgresql":{"parameters":{"wal_level":"logical"}}}
|
||||
Then I receive a response code 200
|
||||
And Response on GET http://127.0.0.1:8008/config contains slots after 10 seconds
|
||||
When I start postgres1
|
||||
And I start postgres2
|
||||
And I configure and start postgres3 with a tag replicatefrom postgres2
|
||||
Then postgres0 has a physical replication slot named test_physical after 10 seconds
|
||||
And postgres0 has a physical replication slot named postgres1 after 10 seconds
|
||||
And postgres0 has a physical replication slot named postgres2 after 10 seconds
|
||||
And postgres2 has a physical replication slot named postgres3 after 10 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that logical permanent slots are created
|
||||
Given I run patronictl.py restart batman postgres0 --force
|
||||
And I issue a PATCH request to http://127.0.0.1:8008/config with {"slots":{"test_logical":{"type":"logical","database":"postgres","plugin":"test_decoding"}}}
|
||||
Then postgres0 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that permanent slots are created on replicas
|
||||
Given postgres1 has a logical replication slot named test_logical with the test_decoding plugin after 10 seconds
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds
|
||||
And postgres1 has a physical replication slot named test_physical after 2 seconds
|
||||
And postgres2 has a physical replication slot named test_physical after 2 seconds
|
||||
And postgres3 has a physical replication slot named test_physical after 2 seconds
|
||||
|
||||
@slot-advance
|
||||
Scenario: check permanent physical slots that match with member names
|
||||
Given postgres0 has a physical replication slot named postgres3 after 2 seconds
|
||||
And postgres1 has a physical replication slot named postgres0 after 2 seconds
|
||||
And postgres1 has a physical replication slot named postgres3 after 2 seconds
|
||||
And postgres2 has a physical replication slot named postgres0 after 2 seconds
|
||||
And postgres2 has a physical replication slot named postgres3 after 2 seconds
|
||||
And postgres2 has a physical replication slot named postgres1 after 2 seconds
|
||||
And postgres1 does not have a replication slot named postgres2
|
||||
And postgres3 does not have a replication slot named postgres2
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that permanent slots are advanced on replicas
|
||||
Given I add the table replicate_me to postgres0
|
||||
When I get all changes from logical slot test_logical on postgres0
|
||||
And I get all changes from physical slot test_physical on postgres0
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And Physical slot test_physical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres0 and postgres2 after 10 seconds
|
||||
And Physical slot test_physical is in sync between postgres0 and postgres2 after 10 seconds
|
||||
And Logical slot test_logical is in sync between postgres0 and postgres3 after 10 seconds
|
||||
And Physical slot test_physical is in sync between postgres0 and postgres3 after 10 seconds
|
||||
And Physical slot postgres1 is in sync between postgres0 and postgres2 after 10 seconds
|
||||
And Physical slot postgres3 is in sync between postgres2 and postgres0 after 20 seconds
|
||||
And Physical slot postgres3 is in sync between postgres2 and postgres1 after 10 seconds
|
||||
And postgres1 does not have a replication slot named postgres2
|
||||
And postgres3 does not have a replication slot named postgres2
|
||||
|
||||
@slot-advance
|
||||
Scenario: check that only permanent slots are written to the /status key
|
||||
Given "status" key in DCS has test_physical in slots
|
||||
And "status" key in DCS has postgres0 in slots
|
||||
And "status" key in DCS has postgres1 in slots
|
||||
And "status" key in DCS does not have postgres2 in slots
|
||||
And "status" key in DCS has postgres3 in slots
|
||||
|
||||
Scenario: check permanent physical replication slot after failover
|
||||
Given I shut down postgres3
|
||||
And I shut down postgres2
|
||||
And I shut down postgres0
|
||||
Then postgres1 has a physical replication slot named test_physical after 10 seconds
|
||||
And postgres1 has a physical replication slot named postgres0 after 10 seconds
|
||||
And postgres1 has a physical replication slot named postgres3 after 10 seconds
|
||||
@@ -0,0 +1,23 @@
|
||||
Feature: priority replication
|
||||
We should check that we can give nodes priority during failover
|
||||
|
||||
Scenario: check failover priority 0 prevents leaderships
|
||||
Given I configure and start postgres0 with a tag failover_priority 1
|
||||
And I configure and start postgres1 with a tag failover_priority 0
|
||||
Then replication works from postgres0 to postgres1 after 20 seconds
|
||||
When I shut down postgres0
|
||||
And 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 postgres0 role is the primary after 10 seconds
|
||||
|
||||
Scenario: check higher failover priority is respected
|
||||
Given I configure and start postgres2 with a tag failover_priority 1
|
||||
And I configure and start postgres3 with a tag failover_priority 2
|
||||
Then replication works from postgres0 to postgres2 after 20 seconds
|
||||
And replication works from postgres0 to postgres3 after 20 seconds
|
||||
When I shut down postgres0
|
||||
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
|
||||
@@ -14,6 +14,8 @@ Feature: recovery
|
||||
Then I receive a response code 200
|
||||
And I receive a response role master
|
||||
And I receive a response timeline 1
|
||||
And "members/postgres0" key in DCS has state=running after 12 seconds
|
||||
And replication works from postgres0 to postgres1 after 15 seconds
|
||||
|
||||
Scenario: check immediate failover when master_start_timeout=0
|
||||
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"master_start_timeout": 0}
|
||||
|
||||
@@ -22,9 +22,6 @@ Feature: standby cluster
|
||||
Scenario: check permanent logical slots are synced to the replica
|
||||
Given I run patronictl.py restart batman postgres1 --force
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
When I add the table replicate_me to postgres1
|
||||
And I get all changes from logical slot test_logical on postgres1
|
||||
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
|
||||
|
||||
Scenario: Detach exiting node from the cluster
|
||||
When I shut down postgres1
|
||||
@@ -54,17 +51,21 @@ Feature: standby cluster
|
||||
When I issue a GET request to http://127.0.0.1:8010/patroni
|
||||
Then I receive a response code 200
|
||||
And I receive a response replication_state streaming
|
||||
And postgres1 does not have a logical replication slot named test_logical
|
||||
And postgres1 does not have a replication slot named test_logical
|
||||
|
||||
Scenario: check switchover
|
||||
Given I run patronictl.py switchover batman1 --force
|
||||
Then Status code on GET http://127.0.0.1:8010/standby_leader is 200 after 10 seconds
|
||||
And postgres1 is replicating from postgres2 after 32 seconds
|
||||
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
|
||||
|
||||
Scenario: check failover
|
||||
When I kill postgres1
|
||||
And I kill postmaster on postgres1
|
||||
Then postgres2 is replicating from postgres0 after 32 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8010/primary
|
||||
When I kill postgres2
|
||||
And I kill postmaster on postgres2
|
||||
Then postgres1 is replicating from postgres0 after 32 seconds
|
||||
And Status code on GET http://127.0.0.1:8009/standby_leader is 200 after 10 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8009/primary
|
||||
Then I receive a response code 503
|
||||
And I sleep for 3 seconds
|
||||
When I issue a GET request to http://127.0.0.1:8010/standby_leader
|
||||
Then I receive a response code 200
|
||||
And I receive a response role standby_leader
|
||||
And replication works from postgres0 to postgres2 after 15 seconds
|
||||
And there is a postgres2_cb.log with "on_start replica batman1\non_role_change standby_leader batman1" in postgres2 data directory
|
||||
And replication works from postgres0 to postgres1 after 15 seconds
|
||||
And there is a postgres1_cb.log with "on_role_change replica batman1\non_role_change standby_leader batman1" in postgres1 data directory
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import patroni.psycopg as pg
|
||||
|
||||
from behave import step, then
|
||||
@@ -35,11 +36,16 @@ def kill_patroni(context, name):
|
||||
return context.pctl.stop(name, kill=True)
|
||||
|
||||
|
||||
@step('I kill postmaster on {name:w}')
|
||||
@step('I shut down postmaster on {name:w}')
|
||||
def stop_postgres(context, name):
|
||||
return context.pctl.stop(name, postgres=True)
|
||||
|
||||
|
||||
@step('I kill postmaster on {name:w}')
|
||||
def kill_postgres(context, name):
|
||||
return context.pctl.stop(name, kill=True, postgres=True)
|
||||
|
||||
|
||||
@step('I add the table {table_name:w} to {pg_name:w}')
|
||||
def add_table(context, table_name, pg_name):
|
||||
# parse the configuration file and get the port
|
||||
@@ -105,11 +111,18 @@ def replication_works(context, primary, replica, time_limit):
|
||||
context.execute_steps(u"""
|
||||
When I add the table test_{0} to {1}
|
||||
Then table test_{0} is present on {2} after {3} seconds
|
||||
""".format(int(time()), primary, replica, time_limit))
|
||||
""".format(str(time()).replace('.', '_').replace(',', '_'), primary, replica, time_limit))
|
||||
|
||||
|
||||
@then('there is a "{message}" {level:w} in the {node} patroni log')
|
||||
def check_patroni_log(context, message, level, node):
|
||||
messsages_of_level = context.pctl.read_patroni_log(node, level)
|
||||
assert any(message in line for line in messsages_of_level), \
|
||||
"There was no {0} {1} in the {2} patroni log".format(message, level, node)
|
||||
@then('there is one of {message_list} {level:w} in the {node} patroni log after {timeout:d} seconds')
|
||||
def check_patroni_log(context, message_list, level, node, timeout):
|
||||
timeout *= context.timeout_multiplier
|
||||
message_list = json.loads(message_list)
|
||||
|
||||
for _ in range(int(timeout)):
|
||||
messsages_of_level = context.pctl.read_patroni_log(node, level)
|
||||
if any(any(message in line for line in messsages_of_level) for message in message_list):
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
assert False, f"There were none of {message_list} {level} in the {node} patroni log after {timeout} seconds"
|
||||
|
||||
@@ -28,7 +28,7 @@ def check_member(context, name, key, value, time_limit):
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
response = json.loads(context.dcs_ctl.query(name))
|
||||
dcs_value = response.get(key)
|
||||
dcs_value = str(response.get(key))
|
||||
if dcs_value == value:
|
||||
return
|
||||
except Exception:
|
||||
|
||||
+12
-6
@@ -115,12 +115,18 @@ def count_rows(context, name):
|
||||
assert rows == context.insert_counter, "Distributed table doesn't have expected amount of rows"
|
||||
|
||||
|
||||
@step("There is a transaction in progress on {name:w} changing pg_dist_node")
|
||||
def check_transaction(context, name):
|
||||
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
|
||||
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'")
|
||||
assert cur.rowcount == 1, "There is no idle in transaction updating pg_dist_node"
|
||||
context.xact_start = cur.fetchone()[0]
|
||||
@step("there is a transaction in progress on {name:w} changing pg_dist_node after {time_limit:d} seconds")
|
||||
def check_transaction(context, name, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
while time.time() < max_time:
|
||||
cur = context.pctl.query(name, "SELECT xact_start FROM pg_stat_activity WHERE pid <> pg_backend_pid()"
|
||||
" AND state = 'idle in transaction' AND query ~ 'citus_update_node'")
|
||||
if cur.rowcount == 1:
|
||||
context.xact_start = cur.fetchone()[0]
|
||||
return
|
||||
time.sleep(1)
|
||||
assert False, f"There is no idle in transaction on {name} updating pg_dist_node after {time_limit} seconds"
|
||||
|
||||
|
||||
@step("a transaction finishes in {timeout:d} seconds")
|
||||
|
||||
+62
-17
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from behave import step, then
|
||||
@@ -15,21 +16,30 @@ def create_logical_replication_slot(context, slot_name, pg_name, plugin):
|
||||
assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
|
||||
|
||||
|
||||
@then('{pg_name:w} has a logical replication slot named {slot_name} with the {plugin:w} plugin')
|
||||
def has_logical_replication_slot(context, pg_name, slot_name, plugin):
|
||||
try:
|
||||
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
|
||||
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
|
||||
assert row, "Couldn't find replication slot named {0}".format(slot_name)
|
||||
assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".format(slot_name)
|
||||
assert row[1] == plugin, ("Found replication slot named {0} but was using plugin "
|
||||
"{1} rather than {2}").format(slot_name, row[1], plugin)
|
||||
except pg.Error:
|
||||
assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
|
||||
@step('{pg_name:w} has a logical replication slot named {slot_name}'
|
||||
' with the {plugin:w} plugin after {time_limit:d} seconds')
|
||||
@then('{pg_name:w} has a logical replication slot named {slot_name}'
|
||||
' with the {plugin:w} plugin after {time_limit:d} seconds')
|
||||
def has_logical_replication_slot(context, pg_name, slot_name, plugin, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
row = context.pctl.query(pg_name, ("SELECT slot_type, plugin FROM pg_replication_slots"
|
||||
f" WHERE slot_name = '{slot_name}'")).fetchone()
|
||||
if row:
|
||||
assert row[0] == "logical", f"Replication slot {slot_name} isn't a logical but {row[0]}"
|
||||
assert row[1] == plugin, f"Replication slot {slot_name} using plugin {row[1]} rather than {plugin}"
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, f"Error looking for slot {slot_name} on {pg_name} with plugin {plugin}"
|
||||
|
||||
|
||||
@then('{pg_name:w} does not have a logical replication slot named {slot_name}')
|
||||
def does_not_have_logical_replication_slot(context, pg_name, slot_name):
|
||||
@step('{pg_name:w} does not have a replication slot named {slot_name:w}')
|
||||
@then('{pg_name:w} does not have a replication slot named {slot_name:w}')
|
||||
def does_not_have_replication_slot(context, pg_name, slot_name):
|
||||
try:
|
||||
row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots"
|
||||
" WHERE slot_name = '{0}'").format(slot_name)).fetchone()
|
||||
@@ -38,13 +48,14 @@ def does_not_have_logical_replication_slot(context, pg_name, slot_name):
|
||||
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
|
||||
|
||||
|
||||
@step('Logical slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
|
||||
def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
|
||||
@step('{slot_type:w} slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
|
||||
def slots_in_sync(context, slot_type, slot_name, pg_name1, pg_name2, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
column = 'confirmed_flush_lsn' if slot_type.lower() == 'logical' else 'restart_lsn'
|
||||
query = f"SELECT {column} FROM pg_replication_slots WHERE slot_name = '{slot_name}'"
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
query = "SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = '{0}'".format(slot_name)
|
||||
slot1 = context.pctl.query(pg_name1, query).fetchone()
|
||||
slot2 = context.pctl.query(pg_name2, query).fetchone()
|
||||
if slot1[0] == slot2[0]:
|
||||
@@ -52,9 +63,43 @@ def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, "Logical slot {0} is not in sync between {1} and {2}".format(slot_name, pg_name1, pg_name2)
|
||||
assert False, \
|
||||
f"{slot_type} slot {slot_name} is not in sync between {pg_name1} and {pg_name2} after {time_limit} seconds"
|
||||
|
||||
|
||||
@step('I get all changes from logical slot {slot_name:w} on {pg_name:w}')
|
||||
def logical_slot_get_changes(context, slot_name, pg_name):
|
||||
context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name))
|
||||
|
||||
|
||||
@step('I get all changes from physical slot {slot_name:w} on {pg_name:w}')
|
||||
def physical_slot_get_changes(context, slot_name, pg_name):
|
||||
context.pctl.query(pg_name, f"SELECT * FROM pg_replication_slot_advance('{slot_name}', pg_current_wal_lsn())")
|
||||
|
||||
|
||||
@step('{pg_name:w} has a physical replication slot named {slot_name} after {time_limit:d} seconds')
|
||||
def has_physical_replication_slot(context, pg_name, slot_name, time_limit):
|
||||
time_limit *= context.timeout_multiplier
|
||||
max_time = time.time() + int(time_limit)
|
||||
query = f"SELECT * FROM pg_catalog.pg_replication_slots WHERE slot_type = 'physical' AND slot_name = '{slot_name}'"
|
||||
while time.time() < max_time:
|
||||
try:
|
||||
row = context.pctl.query(pg_name, query).fetchone()
|
||||
if row:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
assert False, f"Physical slot {slot_name} doesn't exist after {time_limit} seconds"
|
||||
|
||||
|
||||
@step('"{name}" key in DCS has {subkey:w} in {key:w}')
|
||||
def dcs_key_contains(context, name, subkey, key):
|
||||
response = json.loads(context.dcs_ctl.query(name))
|
||||
assert key in response and subkey in response[key], f"{name} key in DCS doesn't have {subkey} in {key}"
|
||||
|
||||
|
||||
@step('"{name}" key in DCS does not have {subkey:w} in {key:w}')
|
||||
def dcs_key_does_not_contain(context, name, subkey, key):
|
||||
response = json.loads(context.dcs_ctl.query(name))
|
||||
assert key not in response or subkey not in response[key], f"{name} key in DCS has {subkey} in {key}"
|
||||
|
||||
@@ -15,9 +15,7 @@ def start_patroni(context, name, cluster_name):
|
||||
"scope": cluster_name,
|
||||
"postgresql": {
|
||||
"callbacks": callbacks(context, name),
|
||||
"backup_restore": {
|
||||
"command": (context.pctl.PYTHON + " features/backup_restore.py --sourcedir="
|
||||
+ os.path.join(context.pctl.patroni_path, 'data', 'basebackup').replace('\\', '/'))}
|
||||
"backup_restore": context.pctl.backup_restore_config()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,6 +32,7 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
|
||||
"ttl": 20,
|
||||
"loop_wait": 2,
|
||||
"retry_timeout": 5,
|
||||
"synchronous_mode": True, # should be completely ignored
|
||||
"standby_cluster": {
|
||||
"host": "localhost",
|
||||
"port": port,
|
||||
|
||||
@@ -9,8 +9,8 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
| xargs apt-get install -y vim-tiny curl jq locales git python3-pip python3-wheel \
|
||||
## Make sure we have a en_US.UTF-8 locale available
|
||||
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||
&& pip3 install setuptools \
|
||||
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& pip3 install --break-system-packages setuptools \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& PGHOME=/home/postgres \
|
||||
&& mkdir -p $PGHOME \
|
||||
&& chown postgres $PGHOME \
|
||||
|
||||
@@ -10,12 +10,24 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
| xargs apt-get install -y busybox vim-tiny curl jq less locales git python3-pip python3-wheel lsb-release \
|
||||
## Make sure we have a en_US.UTF-8 locale available
|
||||
&& localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 \
|
||||
&& echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
|
||||
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||
&& apt-get update -y \
|
||||
&& apt-get -y install postgresql-$PG_MAJOR-citus-11.3 \
|
||||
&& pip3 install setuptools \
|
||||
&& pip3 install 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& if [ $(dpkg --print-architecture) = 'arm64' ]; then \
|
||||
apt-get install -y postgresql-server-dev-15 \
|
||||
gcc make autoconf \
|
||||
libc6-dev flex libcurl4-gnutls-dev \
|
||||
libicu-dev libkrb5-dev liblz4-dev \
|
||||
libpam0g-dev libreadline-dev libselinux1-dev\
|
||||
libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||
&& git clone -b "main" https://github.com/citusdata/citus.git \
|
||||
&& MAKEFLAGS="-j $(grep -c ^processor /proc/cpuinfo)" \
|
||||
&& cd citus && ./configure && make install && cd ../ && rm -rf /citus; \
|
||||
else \
|
||||
echo "deb [signed-by=/etc/apt/trusted.gpg.d/citusdata_community.gpg] https://packagecloud.io/citusdata/community/debian/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/citusdata_community.list \
|
||||
&& curl -sL https://packagecloud.io/citusdata/community/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/citusdata_community.gpg \
|
||||
&& apt-get update -y \
|
||||
&& apt-get -y install postgresql-15-citus-12.0; \
|
||||
fi \
|
||||
&& pip3 install --break-system-packages setuptools \
|
||||
&& pip3 install --break-system-packages 'git+https://github.com/zalando/patroni.git#egg=patroni[kubernetes]' \
|
||||
&& PGHOME=/home/postgres \
|
||||
&& mkdir -p $PGHOME \
|
||||
&& chown postgres $PGHOME \
|
||||
@@ -26,6 +38,9 @@ RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
&& chmod 664 /etc/passwd \
|
||||
# Clean up
|
||||
&& apt-get remove -y git python3-pip python3-wheel \
|
||||
postgresql-server-dev-15 gcc make autoconf \
|
||||
libc6-dev flex libicu-dev libkrb5-dev liblz4-dev \
|
||||
libpam0g-dev libreadline-dev libselinux1-dev libssl-dev libxslt1-dev libzstd-dev uuid-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
||||
|
||||
+7
-53
@@ -3,23 +3,14 @@
|
||||
:var PATRONI_ENV_PREFIX: prefix for Patroni related configuration environment variables.
|
||||
:var KUBERNETES_ENV_PREFIX: prefix for Kubernetes related configuration environment variables.
|
||||
:var MIN_PSYCOPG2: minimum version of :mod:`psycopg2` required by Patroni to work.
|
||||
:var MIN_PSYCOPG3: minimum version of :mod:`psycopg` required by Patroni to work.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from typing import Any, Callable, Iterator, Tuple
|
||||
from typing import Iterator, Tuple
|
||||
|
||||
PATRONI_ENV_PREFIX = 'PATRONI_'
|
||||
KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
|
||||
MIN_PSYCOPG2 = (2, 5, 4)
|
||||
|
||||
|
||||
def fatal(string: str, *args: Any) -> None:
|
||||
"""Write a fatal message to stderr and exit with code ``1``.
|
||||
|
||||
:param string: message to be written before exiting.
|
||||
"""
|
||||
sys.exit('FATAL: ' + string.format(*args))
|
||||
MIN_PSYCOPG3 = (3, 0, 0)
|
||||
|
||||
|
||||
def parse_version(version: str) -> Tuple[int, ...]:
|
||||
@@ -28,25 +19,25 @@ def parse_version(version: str) -> Tuple[int, ...]:
|
||||
.. note::
|
||||
Designed for easy comparison of software versions in Python.
|
||||
|
||||
:param version: human-readable software version, e.g. ``2.5.4``.
|
||||
:param version: human-readable software version, e.g. ``2.5.4.dev1 (dt dec pq3 ext lo64)``.
|
||||
|
||||
:returns: tuple of *version* parts, each part as an integer.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> parse_version('2.5.4')
|
||||
>>> parse_version('2.5.4.dev1 (dt dec pq3 ext lo64)')
|
||||
(2, 5, 4)
|
||||
"""
|
||||
def _parse_version(version: str) -> Iterator[int]:
|
||||
"""Yield each part of a human-readable version string as an integer.
|
||||
|
||||
:param version: human-readable software version, e.g. ``2.5.4``.
|
||||
:param version: human-readable software version, e.g. ``2.5.4.dev1``.
|
||||
|
||||
:yields: each part of *version* as an integer.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> tuple(_parse_version('2.5.4'))
|
||||
>>> tuple(_parse_version('2.5.4.dev1'))
|
||||
(2, 5, 4)
|
||||
"""
|
||||
for e in version.split('.'):
|
||||
@@ -55,40 +46,3 @@ def parse_version(version: str) -> Tuple[int, ...]:
|
||||
except ValueError:
|
||||
break
|
||||
return tuple(_parse_version(version.split(' ')[0]))
|
||||
|
||||
|
||||
def check_psycopg(_min_psycopg2: Tuple[int, ...] = MIN_PSYCOPG2,
|
||||
_parse_version: Callable[[str], Tuple[int, ...]] = parse_version) -> None:
|
||||
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
|
||||
|
||||
.. note::
|
||||
We pass ``MIN_PSYCOPG2`` and :func:`parse_version` as arguments to simplify usage of :func:`check_psycopg` from
|
||||
the ``setup.py``.
|
||||
|
||||
.. note::
|
||||
Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible.
|
||||
|
||||
If nothing meeting the requirements is found, then exit with a fatal message.
|
||||
|
||||
:param _min_psycopg2: minimum required version in case :mod:`psycopg2` is chosen.
|
||||
:param _parse_version: function used to parse :mod:`psycopg2`/:mod:`psycopg` version into a comparable object.
|
||||
"""
|
||||
min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
|
||||
|
||||
# try psycopg2
|
||||
try:
|
||||
from psycopg2 import __version__
|
||||
if _parse_version(__version__) >= _min_psycopg2:
|
||||
return
|
||||
version_str = __version__.split(' ')[0]
|
||||
except ImportError:
|
||||
version_str = None
|
||||
|
||||
# try psycopg3
|
||||
try:
|
||||
from psycopg import __version__
|
||||
except ImportError:
|
||||
error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
|
||||
if version_str is not None:
|
||||
error += ', but only psycopg2=={0} is available'.format(version_str)
|
||||
fatal(error)
|
||||
|
||||
+50
-7
@@ -10,8 +10,9 @@ import sys
|
||||
import time
|
||||
|
||||
from argparse import Namespace
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from patroni import MIN_PSYCOPG2, MIN_PSYCOPG3, parse_version
|
||||
from patroni.daemon import AbstractPatroniDaemon, abstract_main, get_base_arg_parser
|
||||
from patroni.tags import Tags
|
||||
|
||||
@@ -106,6 +107,8 @@ class Patroni(AbstractPatroniDaemon, Tags):
|
||||
|
||||
def ensure_unique_name(self) -> None:
|
||||
"""A helper method to prevent splitbrain from operator naming error."""
|
||||
from urllib.parse import urlparse
|
||||
from urllib3.connection import HTTPConnection
|
||||
from patroni.dcs import Member
|
||||
|
||||
cluster = self.dcs.get_cluster()
|
||||
@@ -115,9 +118,12 @@ class Patroni(AbstractPatroniDaemon, Tags):
|
||||
if not isinstance(member, Member):
|
||||
return
|
||||
try:
|
||||
_ = self.request(member, endpoint="/liveness", timeout=3)
|
||||
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
|
||||
sys.exit(1)
|
||||
parts = urlparse(member.api_url)
|
||||
if isinstance(parts.hostname, str):
|
||||
connection = HTTPConnection(parts.hostname, port=parts.port or 80, timeout=3)
|
||||
connection.connect()
|
||||
logger.fatal("Can't start; there is already a node named '%s' running", self.config['name'])
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@@ -281,6 +287,45 @@ def process_arguments() -> Namespace:
|
||||
return args
|
||||
|
||||
|
||||
def check_psycopg() -> None:
|
||||
"""Ensure at least one among :mod:`psycopg2` or :mod:`psycopg` libraries are available in the environment.
|
||||
|
||||
.. note::
|
||||
Patroni chooses :mod:`psycopg2` over :mod:`psycopg`, if possible.
|
||||
|
||||
If nothing meeting the requirements is found, then exit with a fatal message.
|
||||
"""
|
||||
min_psycopg2_str = '.'.join(map(str, MIN_PSYCOPG2))
|
||||
min_psycopg3_str = '.'.join(map(str, MIN_PSYCOPG3))
|
||||
|
||||
available_versions: List[str] = []
|
||||
|
||||
# try psycopg2
|
||||
try:
|
||||
from psycopg2 import __version__
|
||||
if parse_version(__version__) >= MIN_PSYCOPG2:
|
||||
return
|
||||
available_versions.append('psycopg2=={0}'.format(__version__.split(' ')[0]))
|
||||
except ImportError:
|
||||
logger.debug('psycopg2 module is not available')
|
||||
|
||||
# try psycopg3
|
||||
try:
|
||||
from psycopg import __version__
|
||||
if parse_version(__version__) >= MIN_PSYCOPG3:
|
||||
return
|
||||
available_versions.append('psycopg=={0}'.format(__version__.split(' ')[0]))
|
||||
except ImportError:
|
||||
logger.debug('psycopg module is not available')
|
||||
|
||||
error = f'FATAL: Patroni requires psycopg2>={min_psycopg2_str}, psycopg2-binary, or psycopg>={min_psycopg3_str}'
|
||||
if available_versions:
|
||||
error += ', but only {0} {1} available'.format(
|
||||
' and '.join(available_versions),
|
||||
'is' if len(available_versions) == 1 else 'are')
|
||||
sys.exit(error)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entrypoint of :mod:`patroni.__main__`.
|
||||
|
||||
@@ -292,12 +337,10 @@ 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 patroni import check_psycopg
|
||||
check_psycopg()
|
||||
|
||||
args = process_arguments()
|
||||
|
||||
check_psycopg()
|
||||
|
||||
if os.getpid() != 1:
|
||||
return patroni_main(args.configfile)
|
||||
|
||||
|
||||
+113
-20
@@ -12,6 +12,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
import dateutil.parser
|
||||
import datetime
|
||||
import os
|
||||
import socket
|
||||
@@ -27,11 +28,11 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CH
|
||||
|
||||
from . import psycopg
|
||||
from .__main__ import Patroni
|
||||
from .dcs import Cluster
|
||||
from .exceptions import PostgresConnectionException, PostgresException
|
||||
from .manual_failover import ManualFailover
|
||||
from .postgresql.misc import postgres_version_to_int
|
||||
from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \
|
||||
RetryFailedError, parse_int, parse_schedule, split_host_port, tzutc, uri, cluster_as_json
|
||||
RetryFailedError, parse_int, split_host_port, tzutc, uri, cluster_as_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -102,7 +103,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(server, RestApiServer)
|
||||
super(RestApiHandler, self).__init__(request, client_address, server)
|
||||
self.server: 'RestApiServer' = server
|
||||
self.server: 'RestApiServer' = server # pyright: ignore [reportIncompatibleVariableOverride]
|
||||
self.__start_time: float = 0.0
|
||||
self.path_query: Dict[str, List[str]] = {}
|
||||
|
||||
@@ -450,7 +451,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
Write an HTTP response with JSON content based on the output of :func:`~patroni.utils.cluster_as_json`, with
|
||||
HTTP status ``200`` and the JSON representation of the cluster topology.
|
||||
"""
|
||||
cluster = self.server.patroni.dcs.get_cluster(True)
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
global_config = self.server.patroni.config.get_global_config(cluster)
|
||||
|
||||
response = cluster_as_json(cluster, global_config)
|
||||
@@ -689,7 +690,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
"""
|
||||
request = self._read_json_content()
|
||||
if request:
|
||||
cluster = self.server.patroni.dcs.get_cluster(True)
|
||||
cluster = self.server.patroni.dcs.get_cluster()
|
||||
if not (cluster.config and cluster.config.modify_version):
|
||||
return self.send_error(503)
|
||||
data = cluster.config.data.copy()
|
||||
@@ -776,6 +777,44 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
self.server.patroni.api_sigterm()
|
||||
self.write_response(202, 'shutdown scheduled')
|
||||
|
||||
@staticmethod
|
||||
def parse_schedule(schedule: str,
|
||||
action: str) -> Tuple[Union[int, None], Union[str, None], Union[datetime.datetime, None]]:
|
||||
"""Parse the given *schedule* and validate it.
|
||||
|
||||
:param schedule: a string representing a timestamp, e.g. ``2023-04-14T20:27:00+00:00``.
|
||||
:param action: the action to be scheduled (``restart``, ``switchover``, or ``failover``).
|
||||
|
||||
:returns: a tuple composed of 3 items:
|
||||
|
||||
* Suggested HTTP status code for a response:
|
||||
|
||||
* ``None``: if no issue was faced while parsing, leaving it up to the caller to decide the status; or
|
||||
* ``400``: if no timezone information could be found in *schedule*; or
|
||||
* ``422``: if *schedule* is invalid -- in the past or not parsable.
|
||||
|
||||
* An error message, if any error is faced, otherwise ``None``;
|
||||
* Parsed *schedule*, if able to parse, otherwise ``None``.
|
||||
|
||||
"""
|
||||
error = None
|
||||
scheduled_at = None
|
||||
try:
|
||||
scheduled_at = dateutil.parser.parse(schedule)
|
||||
if scheduled_at.tzinfo is None:
|
||||
error = 'Timezone information is mandatory for the scheduled {0}'.format(action)
|
||||
status_code = 400
|
||||
elif scheduled_at < datetime.datetime.now(tzutc):
|
||||
error = 'Cannot schedule {0} in the past'.format(action)
|
||||
status_code = 422
|
||||
else:
|
||||
status_code = None
|
||||
except (ValueError, TypeError):
|
||||
logger.exception('Invalid scheduled %s time: %s', action, schedule)
|
||||
error = 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601'
|
||||
status_code = 422
|
||||
return status_code, error, scheduled_at
|
||||
|
||||
@check_access
|
||||
def do_POST_restart(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/restart`` path.
|
||||
@@ -831,9 +870,9 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
for k in request:
|
||||
if k == 'schedule':
|
||||
parse_result, request[k] = parse_schedule(request[k])
|
||||
if parse_result:
|
||||
data, status_code = parse_result.value[0], parse_result.value[1]
|
||||
(_, data, request[k]) = self.parse_schedule(request[k], "restart")
|
||||
if _:
|
||||
status_code = _
|
||||
break
|
||||
elif k == 'role':
|
||||
if request[k] not in ('master', 'primary', 'replica'):
|
||||
@@ -983,6 +1022,39 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
logger.debug('Exception occurred during polling %s result: %s', action, e)
|
||||
return 503, action.title() + ' status unknown'
|
||||
|
||||
def is_failover_possible(self, cluster: Cluster, leader: Optional[str], candidate: Optional[str],
|
||||
action: str) -> Optional[str]:
|
||||
"""Checks whether there are nodes that could take over after demoting the primary.
|
||||
|
||||
:param cluster: the Patroni cluster.
|
||||
:param leader: name of the current Patroni leader.
|
||||
:param candidate: name of the Patroni node to be promoted.
|
||||
:param action: the action to be performed (``switchover`` or ``failover``).
|
||||
|
||||
:returns: a string with the error message or ``None`` if good nodes are found.
|
||||
"""
|
||||
is_synchronous_mode = 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:
|
||||
if action == 'switchover' and is_synchronous_mode and not cluster.sync.matches(candidate):
|
||||
return 'candidate name does not match with sync_standby'
|
||||
members = [m for m in cluster.members if m.name == candidate]
|
||||
if not members:
|
||||
return 'candidate does not exists'
|
||||
elif is_synchronous_mode:
|
||||
members = [m for m in cluster.members if cluster.sync.matches(m.name)]
|
||||
if not members:
|
||||
return action + ' is not possible: can not find sync_standby'
|
||||
else:
|
||||
members = [m for m in cluster.members if not cluster.leader or m.name != cluster.leader.name and m.api_url]
|
||||
if not members:
|
||||
return action + ' is not possible: cluster does not have members except leader'
|
||||
for st in self.server.patroni.ha.fetch_nodes_statuses(members):
|
||||
if st.failover_limitation() is None:
|
||||
return None
|
||||
return action + ' is not possible: no good candidates have been found'
|
||||
|
||||
@check_access
|
||||
def do_POST_failover(self, action: str = 'failover') -> None:
|
||||
"""Handle a ``POST`` request to ``/failover`` path.
|
||||
@@ -1011,6 +1083,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
:param action: the action to be performed (``switchover`` or ``failover``).
|
||||
"""
|
||||
request = self._read_json_content()
|
||||
(status_code, data) = (400, '')
|
||||
if not request:
|
||||
return
|
||||
|
||||
@@ -1023,15 +1096,33 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
logger.info("received %s request with leader=%s candidate=%s scheduled_at=%s",
|
||||
action, leader, candidate, scheduled_at)
|
||||
|
||||
manual_failover = ManualFailover(action, cluster, leader, candidate, scheduled_at,
|
||||
global_config.is_paused, global_config.is_synchronous_mode,
|
||||
self.server.patroni)
|
||||
data, status_code = manual_failover.run_precheck().value
|
||||
if action == 'failover' and not candidate:
|
||||
data = 'Failover could be performed only to a specific candidate'
|
||||
elif action == 'switchover' and not leader:
|
||||
data = 'Switchover could be performed only from a specific leader'
|
||||
|
||||
if not data and scheduled_at:
|
||||
parse_result, scheduled_at = manual_failover.parse_scheduled()
|
||||
if parse_result:
|
||||
data, status_code = parse_result.value[0], parse_result.value[1]
|
||||
if action == 'failover':
|
||||
data = "Failover can't be scheduled"
|
||||
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 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:
|
||||
logger.warning('received failover request with leader specifed - performing switchover instead')
|
||||
action = 'switchover'
|
||||
|
||||
if not data and leader == candidate:
|
||||
data = 'Switchover target and source are the same'
|
||||
|
||||
if not data and not scheduled_at:
|
||||
data = self.is_failover_possible(cluster, leader, candidate, action)
|
||||
if data:
|
||||
status_code = 412
|
||||
|
||||
if not data:
|
||||
if self.server.patroni.dcs.manual_failover(leader, candidate, scheduled_at=scheduled_at):
|
||||
@@ -1045,10 +1136,12 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
data = 'failed to write failover key into DCS'
|
||||
status_code = 503
|
||||
|
||||
status_code = status_code or 400
|
||||
self.write_response(status_code, data.format(action=action, leader=leader, candidate=candidate,
|
||||
cluster_name=self.server.patroni.postgresql.scope))
|
||||
# pyright thinks ``status_code`` can be ``None`` because ``parse_schedule`` call may return ``None``. However,
|
||||
# if that's the case, ``status_code`` will be overwritten somewhere between ``parse_schedule`` and
|
||||
# ``write_response`` calls.
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(status_code, int)
|
||||
self.write_response(status_code, data)
|
||||
|
||||
def do_POST_switchover(self) -> None:
|
||||
"""Handle a ``POST`` request to ``/switchover`` path.
|
||||
@@ -1073,7 +1166,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
|
||||
|
||||
patroni = self.server.patroni
|
||||
if patroni.postgresql.citus_handler.is_coordinator() and patroni.ha.is_leader():
|
||||
cluster = patroni.dcs.get_cluster(True)
|
||||
cluster = patroni.dcs.get_cluster()
|
||||
patroni.postgresql.citus_handler.handle_event(cluster, request)
|
||||
self.write_response(200, 'OK')
|
||||
|
||||
|
||||
+90
-4
@@ -16,6 +16,7 @@ from .dcs import ClusterConfig, Cluster
|
||||
from .exceptions import ConfigParseError
|
||||
from .file_perm import pg_perm
|
||||
from .postgresql.config import ConfigHandler
|
||||
from .validator import IntValidator
|
||||
from .utils import deep_compare, parse_bool, parse_int, patch_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -95,8 +96,8 @@ class GlobalConfig(object):
|
||||
|
||||
@property
|
||||
def is_synchronous_mode(self) -> bool:
|
||||
"""``True`` if synchronous replication is requested."""
|
||||
return self.check_mode('synchronous_mode')
|
||||
"""``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:
|
||||
@@ -292,6 +293,7 @@ class Config(object):
|
||||
if validator: # patronictl uses validator=None and we don't want to load anything from local cache in this case
|
||||
self._load_cache()
|
||||
self._cache_needs_saving = False
|
||||
self._validate_failover_tags()
|
||||
|
||||
@property
|
||||
def config_file(self) -> Optional[str]:
|
||||
@@ -399,6 +401,66 @@ class Config(object):
|
||||
except Exception:
|
||||
logger.error('Can not remove temporary file %s', tmpfile)
|
||||
|
||||
def __get_and_maybe_adjust_int_value(self, config: Dict[str, Any], param: str, min_value: int) -> int:
|
||||
"""Get, validate and maybe adjust a *param* integer value from the *config* :class:`dict`.
|
||||
|
||||
.. note:
|
||||
If the value is smaller than provided *min_value* we update the *config*.
|
||||
|
||||
This method may raise an exception if value isn't :class:`int` or cannot be casted to :class:`int`.
|
||||
|
||||
:param config: :class:`dict` object with new global configuration.
|
||||
:param param: name of the configuration parameter we want to read/validate/adjust.
|
||||
:param min_value: the minimum possible value that a given *param* could have.
|
||||
|
||||
:returns: an integer value which corresponds to a provided *param*.
|
||||
"""
|
||||
value = int(config.get(param, self.__DEFAULT_CONFIG[param]))
|
||||
if value < min_value:
|
||||
logger.warning("%s=%d can't be smaller than %d, adjusting...", param, value, min_value)
|
||||
value = config[param] = min_value
|
||||
return value
|
||||
|
||||
def _validate_and_adjust_timeouts(self, config: Dict[str, Any]) -> None:
|
||||
"""Validate and adjust ``loop_wait``, ``retry_timeout``, and ``ttl`` values if necessary.
|
||||
|
||||
Minimum values:
|
||||
|
||||
* ``loop_wait``: 1 second;
|
||||
* ``retry_timeout``: 3 seconds.
|
||||
* ``ttl``: 20 seconds;
|
||||
|
||||
Maximum values:
|
||||
In case if values don't fulfill the following rule, ``retry_timeout`` and ``loop_wait``
|
||||
are reduced so that the rule is fulfilled:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
loop_wait + 2 * retry_timeout <= ttl
|
||||
|
||||
.. note:
|
||||
We prefer to reduce ``loop_wait`` and will reduce ``retry_timeout`` only if ``loop_wait``
|
||||
is already set to a minimal possible value.
|
||||
|
||||
:param config: :class:`dict` object with new global configuration.
|
||||
"""
|
||||
|
||||
min_loop_wait = 1
|
||||
loop_wait = self. __get_and_maybe_adjust_int_value(config, 'loop_wait', min_loop_wait)
|
||||
retry_timeout = self. __get_and_maybe_adjust_int_value(config, 'retry_timeout', 3)
|
||||
ttl = self. __get_and_maybe_adjust_int_value(config, 'ttl', 20)
|
||||
|
||||
if min_loop_wait + 2 * retry_timeout > ttl:
|
||||
config['loop_wait'] = min_loop_wait
|
||||
config['retry_timeout'] = (ttl - min_loop_wait) // 2
|
||||
logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d. '
|
||||
'Adjusting loop_wait from %d to %d and retry_timeout from %d to %d',
|
||||
ttl, loop_wait, min_loop_wait, retry_timeout, config['retry_timeout'])
|
||||
elif loop_wait + 2 * retry_timeout > ttl:
|
||||
config['loop_wait'] = ttl - 2 * retry_timeout
|
||||
logger.warning('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d and retry_timeout=%d.'
|
||||
' Adjusting loop_wait from %d to %d', ttl, retry_timeout, loop_wait, config['loop_wait'])
|
||||
|
||||
# configuration could be either ClusterConfig or dict
|
||||
def set_dynamic_configuration(self, configuration: Union[ClusterConfig, Dict[str, Any]]) -> bool:
|
||||
"""Set dynamic configuration values with given *configuration*.
|
||||
@@ -416,6 +478,7 @@ class Config(object):
|
||||
|
||||
if not deep_compare(self._dynamic_configuration, configuration):
|
||||
try:
|
||||
self._validate_and_adjust_timeouts(configuration)
|
||||
self.__effective_configuration = self._build_effective_configuration(configuration,
|
||||
self._local_configuration)
|
||||
self._dynamic_configuration = configuration
|
||||
@@ -487,8 +550,10 @@ class Config(object):
|
||||
if name not in ConfigHandler.CMDLINE_OPTIONS:
|
||||
pg_params[name] = value
|
||||
elif not is_local:
|
||||
if ConfigHandler.CMDLINE_OPTIONS[name][1](value):
|
||||
pg_params[name] = value
|
||||
validator = ConfigHandler.CMDLINE_OPTIONS[name][1]
|
||||
if validator(value):
|
||||
int_val = parse_int(value) if isinstance(validator, IntValidator) else None
|
||||
pg_params[name] = int_val if isinstance(int_val, int) else value
|
||||
else:
|
||||
logger.warning("postgresql parameter %s=%s failed validation, defaulting to %s",
|
||||
name, value, ConfigHandler.CMDLINE_OPTIONS[name][0])
|
||||
@@ -895,3 +960,24 @@ class Config(object):
|
||||
: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
|
||||
configuration is one where ``nofailover`` is ``True`` but ``failover_priority > 0``, or where
|
||||
``nofailover`` is ``False``, but ``failover_priority <= 0``. Essentially, ``nofailover`` and
|
||||
``failover_priority`` are communicating different things.
|
||||
This checks for this edge case (which is a misconfiguration on the part of the user) and warns them.
|
||||
The behaviour is as if ``failover_priority`` were not provided (i.e ``nofailover`` is the
|
||||
bedrock source of truth)
|
||||
"""
|
||||
tags = self.get('tags', {})
|
||||
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):
|
||||
logger.warning('Conflicting configuration between nofailover: %s and failover_priority: %s. '
|
||||
'Defaulting to nofailover: %s', nofailover_tag, failover_priority_tag, nofailover_tag)
|
||||
|
||||
+101
-54
@@ -9,7 +9,7 @@ import yaml
|
||||
|
||||
from getpass import getuser, getpass
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING, Union
|
||||
from typing import Any, Dict, Iterator, List, Optional, TextIO, Tuple, TYPE_CHECKING, Union
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from psycopg import Cursor
|
||||
from psycopg2 import cursor
|
||||
@@ -17,6 +17,7 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from . import psycopg
|
||||
from .config import Config
|
||||
from .exceptions import PatroniException
|
||||
from .log import PatroniLogger
|
||||
from .postgresql.config import ConfigHandler, parse_dsn
|
||||
from .postgresql.misc import postgres_major_version_to_int
|
||||
from .utils import get_major_version, parse_bool, patch_config, read_stripped
|
||||
@@ -38,7 +39,7 @@ _AUTH_ALLOWED_PARAMETERS_MAPPING = {
|
||||
'gssencmode': 'PGGSSENCMODE',
|
||||
'channel_binding': 'PGCHANNELBINDING'
|
||||
}
|
||||
_NO_VALUE_MSG = '#FIXME'
|
||||
NO_VALUE_MSG = '#FIXME'
|
||||
|
||||
|
||||
def get_address() -> Tuple[str, str]:
|
||||
@@ -50,7 +51,7 @@ def get_address() -> Tuple[str, str]:
|
||||
:returns: tuple consisting of the hostname returned by :func:`~socket.gethostname`
|
||||
and the first element in the sorted list of the addresses returned by :func:`~socket.getaddrinfo`.
|
||||
Sorting guarantees it will prefer IPv4.
|
||||
If an exception occured, hostname and ip values are equal to :data:`~patroni.config_generator._NO_VALUE_MSG`.
|
||||
If an exception occured, hostname and ip values are equal to :data:`~patroni.config_generator.NO_VALUE_MSG`.
|
||||
"""
|
||||
hostname = None
|
||||
try:
|
||||
@@ -59,7 +60,7 @@ def get_address() -> Tuple[str, str]:
|
||||
key=lambda x: x[0])[0][4][0]
|
||||
except Exception as err:
|
||||
logging.warning('Failed to obtain address: %r', err)
|
||||
return _NO_VALUE_MSG, _NO_VALUE_MSG
|
||||
return NO_VALUE_MSG, NO_VALUE_MSG
|
||||
|
||||
|
||||
class AbstractConfigGenerator(abc.ABC):
|
||||
@@ -88,30 +89,42 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
"""Generate a template config for further extension (e.g. in the inherited classes).
|
||||
|
||||
:returns: dictionary with the values gathered from Patroni env, hopefully defined hostname and ip address
|
||||
(otherwise set to :data:`~patroni.config_generator._NO_VALUE_MSG`), and some sane defaults.
|
||||
(otherwise set to :data:`~patroni.config_generator.NO_VALUE_MSG`), and some sane defaults.
|
||||
"""
|
||||
template_config: Dict[str, Any] = {
|
||||
'scope': _NO_VALUE_MSG,
|
||||
'scope': NO_VALUE_MSG,
|
||||
'name': cls._HOSTNAME,
|
||||
'restapi': {
|
||||
'connect_address': cls._IP + ':8008',
|
||||
'listen': cls._IP + ':8008'
|
||||
},
|
||||
'log': {
|
||||
'level': PatroniLogger.DEFAULT_LEVEL,
|
||||
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
|
||||
},
|
||||
'postgresql': {
|
||||
'data_dir': _NO_VALUE_MSG,
|
||||
'connect_address': _NO_VALUE_MSG + ':5432',
|
||||
'listen': _NO_VALUE_MSG + ':5432',
|
||||
'data_dir': NO_VALUE_MSG,
|
||||
'connect_address': cls._IP + ':5432',
|
||||
'listen': cls._IP + ':5432',
|
||||
'bin_dir': '',
|
||||
'authentication': {
|
||||
'superuser': {
|
||||
'username': 'postgres',
|
||||
'password': _NO_VALUE_MSG
|
||||
'password': NO_VALUE_MSG
|
||||
},
|
||||
'replication': {
|
||||
'username': 'replicator',
|
||||
'password': _NO_VALUE_MSG
|
||||
'password': NO_VALUE_MSG
|
||||
}
|
||||
}
|
||||
},
|
||||
'restapi': {
|
||||
'connect_address': cls._IP + ':8008',
|
||||
'listen': cls._IP + ':8008'
|
||||
'tags': {
|
||||
'failover_priority': 1,
|
||||
'noloadbalance': False,
|
||||
'clonefrom': True,
|
||||
'nosync': False,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +143,72 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
def generate(self) -> None:
|
||||
"""Generate config and store in :attr:`~AbstractConfigGenerator.config`."""
|
||||
|
||||
@staticmethod
|
||||
def _format_block(block: Any, line_prefix: str = '') -> str:
|
||||
"""Format a single YAML block.
|
||||
|
||||
.. note::
|
||||
Optionally the formatted block could be indented with the *line_prefix*
|
||||
|
||||
:param block: the object that should be formatted to YAML.
|
||||
:param line_prefix: is used for indentation.
|
||||
|
||||
:returns: a formatted and indented *block*.
|
||||
"""
|
||||
return line_prefix + yaml.safe_dump(block, default_flow_style=False, line_break='\n',
|
||||
allow_unicode=True, indent=2).strip().replace('\n', '\n' + line_prefix)
|
||||
|
||||
def _format_config_section(self, section_name: str) -> Iterator[str]:
|
||||
"""Format and yield as single section of the current :attr:`~AbstractConfigGenerator.config`.
|
||||
|
||||
.. note::
|
||||
If the section is a :class:`dict` object we put an empty line before it.
|
||||
|
||||
:param section_name: a section name in the :attr:`~AbstractConfigGenerator.config`.
|
||||
|
||||
:yields: a formatted section in case if it exists in the :attr:`~AbstractConfigGenerator.config`.
|
||||
"""
|
||||
if section_name in self.config:
|
||||
if isinstance(self.config[section_name], dict):
|
||||
yield ''
|
||||
yield self._format_block({section_name: self.config[section_name]})
|
||||
|
||||
def _format_config(self) -> Iterator[str]:
|
||||
"""Format current :attr:`~AbstractConfigGenerator.config` and enrich it with some comments.
|
||||
|
||||
:yields: formatted lines or blocks that represent a text output of the YAML document.
|
||||
"""
|
||||
for name in ('scope', 'namespace', 'name', 'log', 'restapi', 'ctl' 'citus',
|
||||
'consul', 'etcd', 'etcd3', 'exhibitor', 'kubernetes', 'raft', 'zookeeper'):
|
||||
yield from self._format_config_section(name)
|
||||
|
||||
if 'bootstrap' in self.config:
|
||||
yield '\n# The bootstrap configuration. Works only when the cluster is not yet initialized.'
|
||||
yield '# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!'
|
||||
yield 'bootstrap:'
|
||||
if 'dcs' in self.config['bootstrap']:
|
||||
yield ' # This section will be written into <dcs>:/<namespace>/<scope>/config after initializing'
|
||||
yield ' # new cluster and all other cluster members will use it as a `global configuration`.'
|
||||
yield ' # WARNING! If you want to change any of the parameters that were set up'
|
||||
yield ' # via `bootstrap.dcs` section, please use `patronictl edit-config`!'
|
||||
yield ' dcs:'
|
||||
for name in ('loop_wait', 'retry_timeout', 'ttl'):
|
||||
if name in self.config['bootstrap']['dcs']:
|
||||
yield self._format_block({name: self.config['bootstrap']['dcs'].pop(name)}, ' ')
|
||||
|
||||
for name, value in self.config['bootstrap']['dcs'].items():
|
||||
yield self._format_block({name: value}, ' ')
|
||||
|
||||
for name in ('postgresql', 'watchdog', 'tags'):
|
||||
yield from self._format_config_section(name)
|
||||
|
||||
def _write_config_to_fd(self, fd: TextIO) -> None:
|
||||
"""Format and write current :attr:`~AbstractConfigGenerator.config` to provided file descriptor.
|
||||
|
||||
:param fd: where to write the config file. Could be ``sys.stdout`` or the real file.
|
||||
"""
|
||||
fd.write('\n'.join(self._format_config()))
|
||||
|
||||
def write_config(self) -> None:
|
||||
"""Write current :attr:`~AbstractConfigGenerator.config` to the output file if provided, to stdout otherwise."""
|
||||
if self.output_file:
|
||||
@@ -137,9 +216,9 @@ class AbstractConfigGenerator(abc.ABC):
|
||||
if dir_path and not os.path.isdir(dir_path):
|
||||
os.makedirs(dir_path)
|
||||
with open(self.output_file, 'w', encoding='UTF-8') as output_file:
|
||||
yaml.safe_dump(self.config, output_file, default_flow_style=False, allow_unicode=True)
|
||||
self._write_config_to_fd(output_file)
|
||||
else:
|
||||
yaml.safe_dump(self.config, sys.stdout, default_flow_style=False, allow_unicode=True)
|
||||
self._write_config_to_fd(sys.stdout)
|
||||
|
||||
|
||||
class SampleConfigGenerator(AbstractConfigGenerator):
|
||||
@@ -182,10 +261,13 @@ class SampleConfigGenerator(AbstractConfigGenerator):
|
||||
self.config['bootstrap']['dcs']['postgresql']['parameters'][wal_keep_param] = \
|
||||
ConfigHandler.CMDLINE_OPTIONS[wal_keep_param][0]
|
||||
|
||||
wal_level = 'hot_standby' if self.pg_major < 90600 else 'replica'
|
||||
self.config['bootstrap']['dcs']['postgresql']['parameters']['wal_level'] = wal_level
|
||||
|
||||
self.config['bootstrap']['dcs']['postgresql']['use_pg_rewind'] = True
|
||||
if self.pg_major >= 110000:
|
||||
self.config['postgresql']['authentication'].setdefault(
|
||||
'rewind', {'username': 'rewind_user'}).setdefault('password', _NO_VALUE_MSG)
|
||||
'rewind', {'username': 'rewind_user'}).setdefault('password', NO_VALUE_MSG)
|
||||
|
||||
|
||||
class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
@@ -287,7 +369,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
|
||||
:param cur: connection cursor to use.
|
||||
"""
|
||||
cur.execute("SELECT name, current_setting(name) FROM pg_settings "
|
||||
cur.execute("SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings "
|
||||
"WHERE context <> 'internal' "
|
||||
"AND source IN ('configuration file', 'command line', 'environment variable') "
|
||||
"AND category <> 'Write-Ahead Log / Recovery Target' "
|
||||
@@ -335,7 +417,7 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
getpass('Please enter the user password:')
|
||||
self.config['postgresql']['authentication'] = {
|
||||
'superuser': su_params,
|
||||
'replication': {'username': _NO_VALUE_MSG, 'password': _NO_VALUE_MSG}
|
||||
'replication': {'username': NO_VALUE_MSG, 'password': NO_VALUE_MSG}
|
||||
}
|
||||
|
||||
def _set_conf_files(self) -> None:
|
||||
@@ -411,41 +493,6 @@ class RunningClusterConfigGenerator(AbstractConfigGenerator):
|
||||
def generate_config(output_file: str, sample: bool, dsn: Optional[str]) -> None:
|
||||
"""Generate Patroni configuration file.
|
||||
|
||||
Gather all the available non-internal GUC values having configuration file, postmaster command line or environment
|
||||
variable as a source and store them in the appropriate part of Patroni configuration (``postgresql.parameters`` or
|
||||
``bootstrap.dcs.postgresql.parameters``). Either the provided DSN (takes precedence) or PG ENV vars will be used
|
||||
for the connection. If password is not provided, it should be entered via prompt.
|
||||
|
||||
The created configuration contains:
|
||||
* ``scope``: ``cluster_name`` GUC value or ``PATRONI_SCOPE ENV`` variable value if available.
|
||||
* ``name``: ``PATRONI_NAME`` ENV variable value if set, otherwise hostname.
|
||||
|
||||
* ``bootstrap.dcs``: section with all the parameters (incl. the majority of PG GUCs) set to their default values
|
||||
defined by Patroni and adjusted by the source instances's configuration values.
|
||||
|
||||
* ``postgresql.parameters``: the source instance's ``archive_command``, ``restore_command``,
|
||||
``archive_cleanup_command``, ``recovery_end_command``, ``ssl_passphrase_command``, ``hba_file``, ``ident_file``,
|
||||
``config_file`` GUC values.
|
||||
|
||||
* ``postgresql.bin_dir``: path to Postgres binaries gathered from the running instance or, if not available,
|
||||
the value of ``PATRONI_POSTGRESQL_BIN_DIR`` ENV variable. Otherwise, an empty string.
|
||||
|
||||
* ``postgresql.datadir``: the value gathered from the corresponding PG GUC.
|
||||
* ``postgresql.listen``: source instance's ``listen_addresses`` and port GUC values.
|
||||
* ``postgresql.connect_address``: if possible, generated from the connection params.
|
||||
* ``postgresql.authentication``:
|
||||
|
||||
* superuser and replication users defined (if possible, usernames are set from the respective Patroni ENV vars,
|
||||
otherwise the default ``postgres`` and ``replicator`` values are used).
|
||||
If not a sample config, either DSN or PG ENV vars are used to define superuser authentication parameters.
|
||||
|
||||
* rewind user is defined only for sample config, if PG version can be defined and PG version is >=11
|
||||
(if possible, username is set from the respective Patroni ENV var).
|
||||
|
||||
* ``bootstrap.dcs.postgresql.use_pg_rewind`` set to ``True`` for a sample config only.
|
||||
* ``postgresql.pg_hba`` defaults or the lines gathered from the source instance's ``hba_file``.
|
||||
* ``postgresql.pg_ident`` the lines gathered from the source instance's ``ident_file``.
|
||||
|
||||
:param output_file: Full path to the configuration file to be used. If not provided, result is sent to ``stdout``.
|
||||
:param sample: Optional flag. If set, no source instance will be used - generate config with some sane defaults.
|
||||
:param dsn: Optional DSN string for the local instance to get GUC values from.
|
||||
|
||||
+121
-71
@@ -16,6 +16,8 @@ import click
|
||||
import codecs
|
||||
import copy
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
import dateutil.tz
|
||||
import difflib
|
||||
import io
|
||||
import json
|
||||
@@ -47,9 +49,8 @@ except ImportError: # pragma: no cover
|
||||
from .config import Config, get_global_config
|
||||
from .dcs import get_dcs as _get_dcs, AbstractDCS, Cluster, Member
|
||||
from .exceptions import PatroniException
|
||||
from .manual_failover import ManualFailover
|
||||
from .postgresql.misc import postgres_version_to_int
|
||||
from .utils import cluster_as_json, parse_schedule, patch_config, polling_loop
|
||||
from .utils import cluster_as_json, patch_config, polling_loop
|
||||
from .request import PatroniRequest
|
||||
from .version import __version__
|
||||
|
||||
@@ -164,7 +165,7 @@ class PatronictlPrettyTable(PrettyTable):
|
||||
def parse_dcs(dcs: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Parse a DCS URL.
|
||||
|
||||
:param dcs: the DCS URL in the format ``DCS://HOST:PORT``. ``DCS`` can be one among:
|
||||
:param dcs: the DCS URL in the format ``DCS://HOST:PORT/NAMESPACE``. ``DCS`` can be one among:
|
||||
|
||||
* ``consul``
|
||||
* ``etcd``
|
||||
@@ -173,10 +174,12 @@ def parse_dcs(dcs: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
* ``zookeeper``
|
||||
|
||||
If ``DCS`` is not specified, assume ``etcd`` by default. If ``HOST`` is not specified, assume ``localhost`` by
|
||||
default. If ``PORT`` is not specified, assume the default port of the given ``DCS``.
|
||||
default. If ``PORT`` is not specified, assume the default port of the given ``DCS``. If ``NAMESPACE`` is not
|
||||
specified, use whatever is in config.
|
||||
|
||||
:returns: ``None`` if *dcs* is ``None``, otherwise a dictionary. The dictionary represents *dcs* as if it were
|
||||
parsed from the Patroni configuration file.
|
||||
parsed from the Patroni configuration file. Additionally, if a namespace is specified in *dcs*, return a
|
||||
``namespace`` key with the parsed value.
|
||||
|
||||
:raises:
|
||||
:class:`PatroniCtlException`: if the DCS name in *dcs* is not valid.
|
||||
@@ -194,6 +197,9 @@ def parse_dcs(dcs: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
>>> parse_dcs('etcd3://random.com:2399')
|
||||
{'etcd3': {'host': 'random.com:2399'}}
|
||||
|
||||
>>> parse_dcs('etcd3://random.com:2399/customnamespace')
|
||||
{'etcd3': {'host': 'random.com:2399'}, 'namespace': '/customnamespace'}
|
||||
"""
|
||||
if dcs is None:
|
||||
return None
|
||||
@@ -210,15 +216,21 @@ def parse_dcs(dcs: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
raise PatroniCtlException('Unknown dcs scheme: {}'.format(scheme))
|
||||
|
||||
default = DCS_DEFAULTS[scheme]
|
||||
return yaml.safe_load(default['template'].format(host=parsed.hostname or 'localhost', port=port or default['port']))
|
||||
ret = yaml.safe_load(default['template'].format(host=parsed.hostname or 'localhost', port=port or default['port']))
|
||||
|
||||
if parsed.path and parsed.path.strip() != '/':
|
||||
ret['namespace'] = parsed.path.strip()
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def load_config(path: str, dcs_url: Optional[str]) -> Dict[str, Any]:
|
||||
"""Load configuration file from *path* and optionally override its DCS configuration with *dcs_url*.
|
||||
|
||||
:param path: path to the configuration file.
|
||||
:param dcs_url: the DCS URL in the format ``DCS://HOST:PORT``, e.g. ``etcd3://random.com:2399``. If given override
|
||||
whatever DCS is set in the configuration file.
|
||||
:param dcs_url: the DCS URL in the format ``DCS://HOST:PORT/NAMESPACE``, e.g. ``etcd3://random.com:2399/service``.
|
||||
If given, override whatever DCS and ``namespace`` that are set in the configuration file. See :func:`parse_dcs`
|
||||
for more information.
|
||||
|
||||
:returns: a dictionary representing the configuration.
|
||||
|
||||
@@ -560,9 +572,10 @@ def get_cursor(obj: Dict[str, Any], cluster: Cluster, group: Optional[int], conn
|
||||
from . import psycopg
|
||||
conn = psycopg.connect(**params)
|
||||
cursor = conn.cursor()
|
||||
# If we want ``any`` node we are fine to return the cursor
|
||||
# If we want ``any`` node we are fine to return the cursor. ``None`` is similar to ``any`` at this point, as it's
|
||||
# been dealt with through :func:`get_any_member`.
|
||||
# If we want the Patroni leader node, :func:`get_any_member` already checks that for us
|
||||
if role in ('any', 'leader'):
|
||||
if role in (None, 'any', 'leader'):
|
||||
return cursor
|
||||
|
||||
# If we want something other than ``any`` or ``leader``, then we do not rely only on the DCS information about
|
||||
@@ -644,8 +657,7 @@ def get_members(obj: Dict[str, Any], cluster: Cluster, cluster_name: str, member
|
||||
if member_names:
|
||||
member_names = list(set(member_names) & candidates)
|
||||
if not member_names:
|
||||
raise PatroniCtlException(
|
||||
'No{0} among provided members'.format('t a single cluster member' if role == 'any' else ' ' + role))
|
||||
raise PatroniCtlException('No {0} among provided members'.format(role))
|
||||
elif action != 'reinitialize':
|
||||
member_names = list(candidates)
|
||||
|
||||
@@ -857,9 +869,11 @@ def query_member(obj: Dict[str, Any], cluster: Cluster, group: Optional[int],
|
||||
|
||||
if cursor is None:
|
||||
if member is not None:
|
||||
message = 'No connection to member {0} is available'.format(member)
|
||||
message = f'No connection to member {member} is available'
|
||||
elif role is not None:
|
||||
message = f'No connection to role {role} is available'
|
||||
else:
|
||||
message = 'No connection to role={0} is available'.format(role)
|
||||
message = 'No connection is available'
|
||||
logging.debug(message)
|
||||
return [[timestamp(0), message]], None
|
||||
|
||||
@@ -945,6 +959,43 @@ def check_response(response: urllib3.response.HTTPResponse, member_name: str,
|
||||
return True
|
||||
|
||||
|
||||
def parse_scheduled(scheduled: Optional[str]) -> Optional[datetime.datetime]:
|
||||
"""Parse a string *scheduled* timestamp as a :class:`~datetime.datetime` object.
|
||||
|
||||
:param scheduled: string representation of the timestamp. May also be ``now``.
|
||||
|
||||
:returns: the corresponding :class:`~datetime.datetime` object, if *scheduled* is not ``now``, otherwise ``None``.
|
||||
|
||||
:raises:
|
||||
:class:`PatroniCtlException`: if unable to parse *scheduled* from :class:`str` to :class:`~datetime.datetime`.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> parse_scheduled(None) is None
|
||||
True
|
||||
|
||||
>>> parse_scheduled('now') is None
|
||||
True
|
||||
|
||||
>>> parse_scheduled('2023-05-29T04:32:31')
|
||||
datetime.datetime(2023, 5, 29, 4, 32, 31, tzinfo=tzlocal())
|
||||
|
||||
>>> parse_scheduled('2023-05-29T04:32:31-3')
|
||||
datetime.datetime(2023, 5, 29, 4, 32, 31, tzinfo=tzoffset(None, -10800))
|
||||
"""
|
||||
if scheduled is not None and (scheduled or 'now') != 'now':
|
||||
try:
|
||||
scheduled_at = dateutil.parser.parse(scheduled)
|
||||
if scheduled_at.tzinfo is None:
|
||||
scheduled_at = scheduled_at.replace(tzinfo=dateutil.tz.tzlocal())
|
||||
except (ValueError, TypeError):
|
||||
message = 'Unable to parse scheduled timestamp ({0}). It should be in an unambiguous format (e.g. ISO 8601)'
|
||||
raise PatroniCtlException(message.format(scheduled))
|
||||
return scheduled_at
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@ctl.command('reload', help='Reload cluster member configuration')
|
||||
@click.argument('cluster_name')
|
||||
@click.argument('member_names', nargs=-1)
|
||||
@@ -1024,20 +1075,16 @@ def restart(obj: Dict[str, Any], cluster_name: str, group: Optional[int], member
|
||||
* *version* could not be parsed; or
|
||||
* a restart is attempted against a cluster that is in maintenance mode.
|
||||
"""
|
||||
action = 'restart'
|
||||
cluster = get_dcs(obj, cluster_name, group).get_cluster()
|
||||
|
||||
members = get_members(obj, cluster, cluster_name, member_names, role, force, action, 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+00')
|
||||
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 + ') ',
|
||||
type=str, default='now')
|
||||
scheduled = scheduled if scheduled != 'now' else None
|
||||
|
||||
parse_result, scheduled_at = parse_schedule(scheduled)
|
||||
if parse_result:
|
||||
raise PatroniCtlException(parse_result.value[0].format(action=action))
|
||||
confirm_members_action(members, force, action, scheduled_at)
|
||||
scheduled_at = parse_scheduled(scheduled)
|
||||
confirm_members_action(members, force, 'restart', scheduled_at)
|
||||
|
||||
if p_any:
|
||||
random.shuffle(members)
|
||||
@@ -1179,9 +1226,6 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s
|
||||
click.echo('Current cluster topology')
|
||||
output_members(obj, cluster, cluster_name, group=group)
|
||||
|
||||
# Define everything missing via interactive input or available cluster info (if force mode)
|
||||
|
||||
# Require Citus group
|
||||
if obj.get('citus') and group is None:
|
||||
if force:
|
||||
raise PatroniCtlException('For Citus clusters the --group must me specified')
|
||||
@@ -1192,25 +1236,42 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s
|
||||
|
||||
global_config = get_global_config(cluster)
|
||||
|
||||
# Leader is required for switchover only
|
||||
if action == 'switchover' and leader is None:
|
||||
# leader has to be be defined for switchover only
|
||||
if action == 'switchover':
|
||||
if cluster.leader is None or not cluster.leader.name:
|
||||
raise PatroniCtlException('This cluster has no leader')
|
||||
if force:
|
||||
leader = cluster.leader.name
|
||||
else:
|
||||
prompt = 'Standby Leader' if global_config.is_standby_cluster else 'Primary'
|
||||
leader = click.prompt(prompt, type=str, default=(cluster.leader and cluster.leader.name))
|
||||
|
||||
if leader is None:
|
||||
if force:
|
||||
leader = cluster.leader.name
|
||||
else:
|
||||
prompt = 'Standby Leader' if global_config.is_standby_cluster else 'Primary'
|
||||
leader = click.prompt(prompt, type=str, default=(cluster.leader and cluster.leader.name))
|
||||
|
||||
if cluster.leader.name != leader:
|
||||
raise PatroniCtlException(f'Member {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]
|
||||
# We sort the names for consistent output to the client
|
||||
candidate_names.sort()
|
||||
|
||||
if not candidate_names:
|
||||
raise PatroniCtlException('No candidates found to {0} to'.format(action))
|
||||
|
||||
if candidate is None and not force:
|
||||
# Check if there are any candidates available at all
|
||||
candidate_names = [str(m.name) for m in cluster.members if m.name != leader and not m.nofailover]
|
||||
if not candidate_names:
|
||||
raise PatroniCtlException('No candidates found to {0} to'.format(action))
|
||||
candidate_names.sort() # we sort the names for consistent output to the client
|
||||
candidate = click.prompt('Candidate ' + str(candidate_names), type=str, default='')
|
||||
|
||||
# We allow manual failover to an aync node in the sync mode, so we better ask for the confirmation
|
||||
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:
|
||||
raise PatroniCtlException(
|
||||
f'Member {candidate} does not exist in cluster {cluster_name} or is tagged as nofailover')
|
||||
|
||||
if all((not force,
|
||||
action == 'failover',
|
||||
global_config.is_synchronous_mode,
|
||||
@@ -1219,45 +1280,21 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s
|
||||
if click.confirm(f'Are you sure you want to failover to the asynchronous node {candidate}'):
|
||||
raise PatroniCtlException('Aborting ' + action)
|
||||
|
||||
if action == 'switchover' and scheduled is None and not force:
|
||||
next_hour = (datetime.datetime.now() + datetime.timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M+00')
|
||||
scheduled = click.prompt('When should the switchover take place (e.g. ' + next_hour + ') ',
|
||||
type=str, default='now')
|
||||
scheduled = scheduled if scheduled != 'now' else None
|
||||
|
||||
# Now, when we collected all the possible info, run checks
|
||||
manual_failover = ManualFailover(action, cluster, leader, candidate, scheduled,
|
||||
global_config.is_paused, global_config.is_synchronous_mode)
|
||||
|
||||
result_text, _ = manual_failover.run_precheck().value
|
||||
if result_text:
|
||||
raise PatroniCtlException(result_text.format(action=action, leader=leader, candidate=candidate,
|
||||
cluster_name=cluster_name))
|
||||
|
||||
scheduled_at_str = None
|
||||
scheduled_at = None
|
||||
|
||||
if action == 'switchover':
|
||||
parse_result, scheduled_at = manual_failover.parse_scheduled()
|
||||
if parse_result:
|
||||
raise PatroniCtlException(parse_result.value[0].format(action=action))
|
||||
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 switchover take place (e.g. ' + next_hour + ' ) ',
|
||||
type=str, default='now')
|
||||
|
||||
scheduled_at = parse_scheduled(scheduled)
|
||||
if scheduled_at:
|
||||
if global_config.is_paused:
|
||||
raise PatroniCtlException("Can't schedule switchover in the paused state")
|
||||
scheduled_at_str = scheduled_at.isoformat()
|
||||
|
||||
# By now we have established that the leader exists and the candidate exists,
|
||||
# so confirm the action that is about to be run
|
||||
if not force:
|
||||
demote_msg = f', demoting current leader {cluster.leader.name}' 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 a switchover in the cluster '
|
||||
f'{cluster_name} at {scheduled_at_str}{demote_msg}?'):
|
||||
# action as a var to catch a regression in the tests
|
||||
raise PatroniCtlException('Aborting scheduled ' + action)
|
||||
else:
|
||||
if not click.confirm(f'Are you sure you want to perform a {action} in the cluster {cluster_name}{demote_msg}?'):
|
||||
raise PatroniCtlException('Aborting ' + action)
|
||||
|
||||
# And finally the actual work
|
||||
failover_value = {'candidate': candidate}
|
||||
if action == 'switchover':
|
||||
failover_value['leader'] = leader
|
||||
@@ -1266,6 +1303,19 @@ def _do_failover_or_switchover(obj: Dict[str, Any], action: str, cluster_name: s
|
||||
|
||||
logging.debug(failover_value)
|
||||
|
||||
# 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 ''
|
||||
if scheduled_at_str:
|
||||
# only switchover can be scheduled
|
||||
if not click.confirm(f'Are you sure you want to schedule switchover of cluster '
|
||||
f'{cluster_name} at {scheduled_at_str}{demote_msg}?'):
|
||||
# action as a var to catch a regression in the tests
|
||||
raise PatroniCtlException('Aborting scheduled ' + action)
|
||||
else:
|
||||
if not click.confirm(f'Are you sure you want to {action} cluster {cluster_name}{demote_msg}?'):
|
||||
raise PatroniCtlException('Aborting ' + action)
|
||||
|
||||
r = None
|
||||
try:
|
||||
member = cluster.leader.member if cluster.leader else candidate and cluster.get_member(candidate, False)
|
||||
|
||||
+197
-69
@@ -24,10 +24,12 @@ import dateutil.parser
|
||||
from ..exceptions import PatroniFatalException
|
||||
from ..utils import deep_compare, uri
|
||||
from ..tags import Tags
|
||||
from ..utils import parse_int
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ..config import Config
|
||||
|
||||
SLOT_ADVANCE_AVAILABLE_VERSION = 110000
|
||||
CITUS_COORDINATOR_GROUP_ID = 0
|
||||
citus_group_re = re.compile('^(0|[1-9][0-9]*)$')
|
||||
slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
|
||||
@@ -157,7 +159,7 @@ 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 modules returned by :func:`iter_dcs_modules` attempt to dynamically import and
|
||||
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
|
||||
@@ -350,6 +352,11 @@ class Member(Tags, NamedTuple('Member',
|
||||
logger.debug('Failed to parse Patroni version %s', version)
|
||||
return None
|
||||
|
||||
@property
|
||||
def lsn(self) -> Optional[int]:
|
||||
"""Current LSN (receive/flush/replay)."""
|
||||
return parse_int(self.data.get('xlog_location'))
|
||||
|
||||
|
||||
class RemoteMember(Member):
|
||||
"""Represents a remote member (typically a primary) for a standby cluster.
|
||||
@@ -451,7 +458,7 @@ class Leader(NamedTuple):
|
||||
|
||||
|
||||
class Failover(NamedTuple):
|
||||
"""Immutable object (namedtuple) which represents failover key.
|
||||
"""Immutable object (namedtuple) representing configuration information required for failover/switchover capability.
|
||||
|
||||
:ivar version: version of the object.
|
||||
:ivar leader: name of the leader. If value isn't empty we treat it as a switchover from the specified node.
|
||||
@@ -547,13 +554,6 @@ class Failover(NamedTuple):
|
||||
"""
|
||||
return int(bool(self.leader)) + int(bool(self.candidate))
|
||||
|
||||
@property
|
||||
def is_switchover(self) -> bool:
|
||||
return bool(self.leader)
|
||||
|
||||
@property
|
||||
def is_failover(self) -> bool:
|
||||
return not self.is_switchover
|
||||
|
||||
class ClusterConfig(NamedTuple):
|
||||
"""Immutable object (namedtuple) which represents cluster configuration.
|
||||
@@ -785,16 +785,71 @@ class TimelineHistory(NamedTuple):
|
||||
return TimelineHistory(version, value, lines)
|
||||
|
||||
|
||||
class Status(NamedTuple):
|
||||
"""Immutable object (namedtuple) which represents `/status` key.
|
||||
|
||||
Consists of the following fields:
|
||||
|
||||
:ivar last_lsn: :class:`int` object containing position of last known leader LSN.
|
||||
:ivar slots: state of permanent replication slots on the primary in the format: ``{"slot_name": int}``.
|
||||
"""
|
||||
last_lsn: int
|
||||
slots: Optional[Dict[str, int]]
|
||||
|
||||
@staticmethod
|
||||
def empty() -> 'Status':
|
||||
"""Construct an empty :class:`Status` instance.
|
||||
|
||||
:returns: empty :class:`Status` object.
|
||||
"""
|
||||
return Status(0, None)
|
||||
|
||||
@staticmethod
|
||||
def from_node(value: Union[str, Dict[str, Any], None]) -> 'Status':
|
||||
"""Factory method to parse *value* as :class:`Status` object.
|
||||
|
||||
:param value: JSON serialized string
|
||||
|
||||
:returns: constructed :class:`Status` object.
|
||||
"""
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
except Exception:
|
||||
return Status.empty()
|
||||
|
||||
if isinstance(value, int): # legacy
|
||||
return Status(value, None)
|
||||
|
||||
if not isinstance(value, dict):
|
||||
return Status.empty()
|
||||
|
||||
try:
|
||||
last_lsn = int(value.get('optime', ''))
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
slots: Union[str, Dict[str, int], None] = value.get('slots')
|
||||
if isinstance(slots, str):
|
||||
try:
|
||||
slots = json.loads(slots)
|
||||
except Exception:
|
||||
slots = None
|
||||
if not isinstance(slots, dict):
|
||||
slots = None
|
||||
|
||||
return Status(last_lsn, slots)
|
||||
|
||||
|
||||
class Cluster(NamedTuple('Cluster',
|
||||
[('initialize', Optional[str]),
|
||||
('config', Optional[ClusterConfig]),
|
||||
('leader', Optional[Leader]),
|
||||
('last_lsn', int),
|
||||
('status', Status),
|
||||
('members', List[Member]),
|
||||
('failover', Optional[Failover]),
|
||||
('sync', SyncState),
|
||||
('history', Optional[TimelineHistory]),
|
||||
('slots', Optional[Dict[str, int]]),
|
||||
('failsafe', Optional[Dict[str, str]]),
|
||||
('workers', Dict[int, 'Cluster'])])):
|
||||
"""Immutable object (namedtuple) which represents PostgreSQL or Citus cluster.
|
||||
@@ -808,13 +863,11 @@ class Cluster(NamedTuple('Cluster',
|
||||
:ivar initialize: shows whether this cluster has initialization key stored in DC or not.
|
||||
:ivar config: global dynamic configuration, reference to `ClusterConfig` object.
|
||||
:ivar leader: :class:`Leader` object which represents current leader of the cluster.
|
||||
:ivar last_lsn: :class:int object containing position of last known leader LSN.
|
||||
This value is stored in the `/status` key or `/optime/leader` (legacy) key.
|
||||
:ivar status: :class:`Status` object which represents the `/status` key.
|
||||
:ivar members: list of:class:` Member` objects, all PostgreSQL cluster members including leader
|
||||
:ivar failover: reference to :class:`Failover` object.
|
||||
:ivar sync: reference to :class:`SyncState` object, last observed synchronous replication state.
|
||||
:ivar history: reference to `TimelineHistory` object.
|
||||
:ivar slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}.
|
||||
:ivar failsafe: failsafe topology. Node is allowed to become the leader only if its name is found in this list.
|
||||
:ivar workers: dictionary of workers of the Citus cluster, optional. Each key is an :class:`int` representing
|
||||
the group, and the corresponding value is a :class:`Cluster` instance.
|
||||
@@ -826,10 +879,20 @@ class Cluster(NamedTuple('Cluster',
|
||||
kwargs['workers'] = {}
|
||||
return super(Cluster, cls).__new__(cls, *args, **kwargs)
|
||||
|
||||
@property
|
||||
def last_lsn(self) -> int:
|
||||
"""Last known leader LSN."""
|
||||
return self.status.last_lsn
|
||||
|
||||
@property
|
||||
def slots(self) -> Optional[Dict[str, int]]:
|
||||
"""State of permanent replication slots on the primary in the format: ``{"slot_name": int}``."""
|
||||
return self.status.slots
|
||||
|
||||
@staticmethod
|
||||
def empty() -> 'Cluster':
|
||||
"""Produce an empty :class:`Cluster` instance."""
|
||||
return Cluster(None, None, None, 0, [], None, SyncState.empty(), None, None, None, {})
|
||||
return Cluster(None, None, None, Status.empty(), [], None, SyncState.empty(), None, None, {})
|
||||
|
||||
def is_empty(self):
|
||||
"""Validate definition of all attributes of this :class:`Cluster` instance.
|
||||
@@ -852,7 +915,7 @@ class Cluster(NamedTuple('Cluster',
|
||||
|
||||
>>> assert bool(cluster) is False
|
||||
|
||||
>>> cluster = Cluster(None, None, None, 0, [1, 2, 3], None, SyncState.empty(), None, None, None, {})
|
||||
>>> cluster = Cluster(None, None, None, Status(0, None), [1, 2, 3], None, SyncState.empty(), None, None, {})
|
||||
>>> len(cluster)
|
||||
1
|
||||
|
||||
@@ -908,30 +971,59 @@ class Cluster(NamedTuple('Cluster',
|
||||
candidates = [m for m in self.members if m.clonefrom and m.is_running and m.name not in exclude]
|
||||
return candidates[randint(0, len(candidates) - 1)] if candidates else self.leader
|
||||
|
||||
@staticmethod
|
||||
def is_physical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
|
||||
"""Check whether provided configuration is for permanent physical replication slot.
|
||||
|
||||
:param value: configuration of the permanent replication slot.
|
||||
|
||||
:returns: ``True`` if *value* is a physical replication slot, otherwise ``False``.
|
||||
"""
|
||||
return not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'
|
||||
|
||||
@staticmethod
|
||||
def is_logical_slot(value: Union[Any, Dict[str, Any]]) -> bool:
|
||||
"""Check whether provided configuration is for permanent logical replication slot.
|
||||
|
||||
:param value: configuration of the permanent replication slot.
|
||||
|
||||
:returns: ``True`` if *value* is a logical replication slot, otherwise ``False``.
|
||||
"""
|
||||
return isinstance(value, dict) \
|
||||
and value.get('type', 'logical') == 'logical' \
|
||||
and bool(value.get('database') and value.get('plugin'))
|
||||
|
||||
@property
|
||||
def __permanent_slots(self) -> Dict[str, Union[Dict[str, Any], Any]]:
|
||||
"""Dictionary of permanent replication slots with their known LSN."""
|
||||
ret = deepcopy(self.config.permanent_slots if self.config else {})
|
||||
# If primary reported flush LSN for permanent slots we want to enrich our structure with it
|
||||
for name, lsn in (self.slots or {}).items():
|
||||
if name in ret:
|
||||
if not ret[name]:
|
||||
ret[name] = {}
|
||||
if isinstance(ret[name], dict):
|
||||
ret[name]['lsn'] = lsn
|
||||
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()}
|
||||
for name, value in list(ret.items()):
|
||||
if not value:
|
||||
value = ret[name] = {}
|
||||
if isinstance(value, dict):
|
||||
# for permanent physical slots we want to get MAX LSN from the `Cluster.slots` and from the
|
||||
# member with the matching name. It is necessary because we may have the replication slot on
|
||||
# the primary that is streaming from the other standby node using the `replicatefrom` tag.
|
||||
lsn = max(members.get(name, 0) if self.is_physical_slot(value) else 0, slots.get(name, 0))
|
||||
if lsn:
|
||||
value['lsn'] = lsn
|
||||
else:
|
||||
# Don't let anyone set 'lsn' in the global configuration :)
|
||||
value.pop('lsn', None)
|
||||
return ret
|
||||
|
||||
@property
|
||||
def __permanent_physical_slots(self) -> Dict[str, Any]:
|
||||
"""Dictionary of permanent ``physical`` replication slots."""
|
||||
return {name: value for name, value in self.__permanent_slots.items()
|
||||
if not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'}
|
||||
return {name: value for name, value in self.__permanent_slots.items() if self.is_physical_slot(value)}
|
||||
|
||||
@property
|
||||
def __permanent_logical_slots(self) -> Dict[str, Any]:
|
||||
"""Dictionary of permanent ``logical`` replication slots."""
|
||||
return {name: value for name, value in self.__permanent_slots.items() if isinstance(value, dict)
|
||||
and value.get('type', 'logical') == 'logical' and value.get('database') and value.get('plugin')}
|
||||
return {name: value for name, value in self.__permanent_slots.items() if self.is_logical_slot(value)}
|
||||
|
||||
@property
|
||||
def use_slots(self) -> bool:
|
||||
@@ -957,7 +1049,9 @@ class Cluster(NamedTuple('Cluster',
|
||||
:returns: final dictionary of slot names, after merging with permanent slots and performing sanity checks.
|
||||
"""
|
||||
slots: Dict[str, Dict[str, str]] = self._get_members_slots(my_name, role)
|
||||
permanent_slots: Dict[str, Any] = self._get_permanent_slots(is_standby_cluster, role, nofailover)
|
||||
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, my_name, major_version)
|
||||
@@ -968,8 +1062,7 @@ class Cluster(NamedTuple('Cluster',
|
||||
|
||||
return slots
|
||||
|
||||
@staticmethod
|
||||
def _merge_permanent_slots(slots: Dict[str, Dict[str, str]], permanent_slots: Dict[str, Any], my_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*.
|
||||
|
||||
@@ -1004,8 +1097,8 @@ class Cluster(NamedTuple('Cluster',
|
||||
slots[name] = value
|
||||
continue
|
||||
|
||||
if value['type'] == 'logical' and value.get('database') and value.get('plugin'):
|
||||
if major_version < 110000:
|
||||
if self.is_logical_slot(value):
|
||||
if major_version < SLOT_ADVANCE_AVAILABLE_VERSION:
|
||||
disabled_permanent_logical_slots.append(name)
|
||||
elif name in slots:
|
||||
logger.error("Permanent logical replication slot {'%s': %s} is conflicting with"
|
||||
@@ -1017,7 +1110,8 @@ class Cluster(NamedTuple('Cluster',
|
||||
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, is_standby_cluster: bool, role: str, nofailover: bool) -> 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::
|
||||
@@ -1033,6 +1127,7 @@ class Cluster(NamedTuple('Cluster',
|
||||
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.
|
||||
"""
|
||||
@@ -1040,9 +1135,11 @@ class Cluster(NamedTuple('Cluster',
|
||||
return {}
|
||||
|
||||
if is_standby_cluster:
|
||||
return self.__permanent_physical_slots if role == 'standby_leader' else {}
|
||||
return self.__permanent_physical_slots \
|
||||
if major_version >= SLOT_ADVANCE_AVAILABLE_VERSION or role == 'standby_leader' else {}
|
||||
|
||||
return self.__permanent_slots if role in ('master', 'primary') else self.__permanent_logical_slots
|
||||
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, my_name: str, role: str) -> Dict[str, Dict[str, str]]:
|
||||
"""Get physical replication slots configuration for members that sourcing from this node.
|
||||
@@ -1087,21 +1184,63 @@ class Cluster(NamedTuple('Cluster',
|
||||
for k, v in slot_conflicts.items() if len(v) > 1))
|
||||
return slots
|
||||
|
||||
def has_permanent_logical_slots(self, my_name: str, nofailover: bool, major_version: int = 110000) -> bool:
|
||||
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 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(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, 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, 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 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 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(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, my_name: str, nofailover: bool) -> bool:
|
||||
"""Check if the given member node has permanent ``logical`` replication slots configured.
|
||||
|
||||
:param my_name: name of the member node to check.
|
||||
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
|
||||
:param major_version: the PostgreSQL major version number.
|
||||
|
||||
:returns: ``False`` if PostgreSQL is < 11, ``True`` if any detected replications slots are ``logical``.
|
||||
:returns: ``True`` if any detected replications slots are ``logical``, otherwise ``False``.
|
||||
"""
|
||||
if major_version < 110000:
|
||||
return False
|
||||
slots = self.get_replication_slots(my_name, 'replica', nofailover, major_version).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, my_name: str, nofailover: bool, major_version: int) -> 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,
|
||||
@@ -1109,20 +1248,16 @@ class Cluster(NamedTuple('Cluster',
|
||||
|
||||
:param my_name: name of the member node to check.
|
||||
:param nofailover: ``True`` if this node is tagged to not be a failover candidate.
|
||||
:param major_version: PostgreSQL major version number.
|
||||
|
||||
:returns: ``True`` if this node or any member replicating from this node has permanent logical slots.
|
||||
``False`` if PostgreSQL major version is < 11.
|
||||
:returns: ``True`` if this node or any member replicating from this node has
|
||||
permanent logical slots, otherwise ``False``.
|
||||
"""
|
||||
if major_version < 110000:
|
||||
return False
|
||||
|
||||
if self.has_permanent_logical_slots(my_name, nofailover, major_version):
|
||||
if self._has_permanent_logical_slots(my_name, nofailover):
|
||||
return True
|
||||
|
||||
if self.use_slots:
|
||||
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader_name]
|
||||
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover, major_version) for m in members)
|
||||
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover) for m in members)
|
||||
return False
|
||||
|
||||
def get_my_slot_name_on_primary(self, my_name: str, replicatefrom: Optional[str]) -> str:
|
||||
@@ -1154,19 +1289,20 @@ class Cluster(NamedTuple('Cluster',
|
||||
:Example:
|
||||
|
||||
No history provided:
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0, 0, None, {}).timeline
|
||||
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, 0, None, {}).timeline
|
||||
0
|
||||
|
||||
Empty history assume timeline is ``1``:
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]'), 0, None, {}).timeline
|
||||
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[]'), None, {}).timeline
|
||||
1
|
||||
|
||||
Invalid history format, a string of ``a``, returns ``0``:
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), 0, None, {}).timeline
|
||||
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), None, {}).timeline
|
||||
0
|
||||
|
||||
History as a list of strings:
|
||||
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["3", "2", "1"]]'), 0, None, {}).timeline
|
||||
>>> history = TimelineHistory.from_node(1, '[["3", "2", "1"]]')
|
||||
>>> Cluster(0, 0, 0, Status.empty(), 0, 0, 0, history, None, {}).timeline
|
||||
4
|
||||
"""
|
||||
if self.history:
|
||||
@@ -1455,9 +1591,6 @@ class AbstractDCS(abc.ABC):
|
||||
primary and exception raised, instance would be demoted.
|
||||
"""
|
||||
|
||||
def _bypass_caches(self) -> None:
|
||||
"""Used only in Zookeeper."""
|
||||
|
||||
def __get_patroni_cluster(self, path: Optional[str] = None) -> Cluster:
|
||||
"""Low level method to load a :class:`Cluster` object from DCS.
|
||||
|
||||
@@ -1500,15 +1633,14 @@ class AbstractDCS(abc.ABC):
|
||||
dict.
|
||||
"""
|
||||
groups = self._load_cluster(self._base_path + '/', self._citus_cluster_loader)
|
||||
if isinstance(groups, Cluster): # Zookeeper could return a cached version
|
||||
cluster = groups
|
||||
else:
|
||||
cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
|
||||
cluster.workers.update(groups)
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert isinstance(groups, dict)
|
||||
cluster = groups.pop(CITUS_COORDINATOR_GROUP_ID, Cluster.empty())
|
||||
cluster.workers.update(groups)
|
||||
return cluster
|
||||
|
||||
def get_cluster(self, force: bool = False) -> Cluster:
|
||||
"""Retrieve an appropriate cached or fresh view of DCS.
|
||||
def get_cluster(self) -> Cluster:
|
||||
"""Retrieve a fresh view of DCS.
|
||||
|
||||
.. note::
|
||||
Stores copy of time, status and failsafe values for comparison in DCS update decisions.
|
||||
@@ -1516,12 +1648,8 @@ class AbstractDCS(abc.ABC):
|
||||
|
||||
Returns either a Citus or Patroni implementation of :class:`Cluster` depending on availability.
|
||||
|
||||
:param force: a value of ``True`` will override Zookeeper caching features.
|
||||
|
||||
:returns:
|
||||
"""
|
||||
if force:
|
||||
self._bypass_caches()
|
||||
try:
|
||||
cluster = self._get_citus_cluster() if self.is_citus_coordinator() else self.__get_patroni_cluster()
|
||||
except Exception:
|
||||
|
||||
+4
-19
@@ -15,7 +15,7 @@ from urllib3.exceptions import HTTPError
|
||||
from urllib.parse import urlencode, urlparse, quote
|
||||
from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare, parse_bool, Retry, RetryFailedError, split_host_port, uri, USER_AGENT
|
||||
@@ -383,23 +383,8 @@ class Consul(AbstractDCS):
|
||||
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value'])
|
||||
|
||||
# get last known leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['Value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['Value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn or '')
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
|
||||
status = Status.from_node(status and status['Value'])
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -428,7 +413,7 @@ class Consul(AbstractDCS):
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
@property
|
||||
def _consistency(self) -> str:
|
||||
|
||||
+4
-19
@@ -21,7 +21,7 @@ from urllib.parse import urlparse
|
||||
from urllib3 import Timeout
|
||||
from urllib3.exceptions import HTTPError, ReadTimeoutError, ProtocolError
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, ReturnFalseException, catch_return_false_exception, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..request import get as requests_get
|
||||
@@ -677,23 +677,8 @@ class Etcd(AbstractEtcd):
|
||||
history = history and TimelineHistory.from_node(history.modifiedIndex, history.value)
|
||||
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status.value)
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn.value
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn or '')
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
|
||||
status = Status.from_node(status and status.value)
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -722,7 +707,7 @@ class Etcd(AbstractEtcd):
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
result = self.retry(self._client.read, path, recursive=True, quorum=self._ctl)
|
||||
|
||||
+72
-59
@@ -15,7 +15,7 @@ from urllib3.exceptions import ReadTimeoutError, ProtocolError
|
||||
from threading import Condition, Lock, Thread
|
||||
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
||||
|
||||
from . import ClusterConfig, Cluster, Failover, Leader, Member, SyncState, \
|
||||
from . import ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, catch_return_false_exception, citus_group_re
|
||||
from .etcd import AbstractEtcdClientWithFailover, AbstractEtcd, catch_etcd_errors, DnsCachingResolver, Retry
|
||||
from ..exceptions import DCSError, PatroniException
|
||||
@@ -124,6 +124,10 @@ class AuthFailed(InvalidArgument):
|
||||
error = "etcdserver: authentication failed, invalid user ID or password"
|
||||
|
||||
|
||||
class AuthOldRevision(InvalidArgument):
|
||||
error = "etcdserver: revision of auth store is old"
|
||||
|
||||
|
||||
class PermissionDenied(Etcd3ClientError):
|
||||
code = GRPCCode.PermissionDenied
|
||||
error = "etcdserver: permission denied"
|
||||
@@ -193,6 +197,12 @@ def build_range_request(key: str, range_end: Union[bytes, str, None] = None) ->
|
||||
return fields
|
||||
|
||||
|
||||
class ReAuthenticateMode(IntEnum):
|
||||
NOT_REQUIRED = 0
|
||||
REQUIRED = 1
|
||||
WITHOUT_WATCHER_RESTART = 2
|
||||
|
||||
|
||||
def _handle_auth_errors(func: Callable[..., Any]) -> Any:
|
||||
def wrapper(self: 'Etcd3Client', *args: Any, **kwargs: Any) -> Any:
|
||||
return self.handle_auth_errors(func, *args, **kwargs)
|
||||
@@ -204,8 +214,9 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
ERROR_CLS = Etcd3Error
|
||||
|
||||
def __init__(self, config: Dict[str, Any], dns_resolver: DnsCachingResolver, cache_ttl: int = 300) -> None:
|
||||
self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||
self._token = None
|
||||
self._cluster_version: Tuple[int] = tuple()
|
||||
self._cluster_version: Tuple[int, ...] = tuple()
|
||||
super(Etcd3Client, self).__init__({**config, 'version_prefix': '/v3beta'}, dns_resolver, cache_ttl)
|
||||
|
||||
try:
|
||||
@@ -282,7 +293,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
fields['retry'] = retry
|
||||
return self.api_execute(self.version_prefix + method, self._MPOST, fields)
|
||||
|
||||
def authenticate(self) -> bool:
|
||||
def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool:
|
||||
if self._use_proxies and not self._cluster_version:
|
||||
kwargs = self._prepare_common_parameters(1)
|
||||
self._ensure_version_prefix(self._base_uri, **kwargs)
|
||||
@@ -291,7 +302,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
logger.info('Trying to authenticate on Etcd...')
|
||||
old_token, self._token = self._token, None
|
||||
try:
|
||||
response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password})
|
||||
response = self.call_rpc('/auth/authenticate', {'name': self.username, 'password': self.password}, retry)
|
||||
except AuthNotEnabled:
|
||||
logger.info('Etcd authentication is not enabled')
|
||||
self._token = None
|
||||
@@ -302,48 +313,65 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
self._token = response.get('token')
|
||||
return old_token != self._token
|
||||
|
||||
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
def retry(ex: Exception) -> Any:
|
||||
if self.username and self.password:
|
||||
self.authenticate()
|
||||
return func(self, *args, **kwargs)
|
||||
else:
|
||||
logger.fatal('Username or password not set, authentication is not possible')
|
||||
raise ex
|
||||
def handle_auth_errors(self: 'Etcd3Client', func: Callable[..., Any], *args: Any,
|
||||
retry: Optional[Retry] = None, **kwargs: Any) -> Any:
|
||||
exc = None
|
||||
while True:
|
||||
if self._reauthenticate_reason:
|
||||
if self.username and self.password:
|
||||
self.authenticate(
|
||||
restart_watcher=self._reauthenticate_reason != ReAuthenticateMode.WITHOUT_WATCHER_RESTART,
|
||||
retry=retry)
|
||||
self._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||
if retry:
|
||||
retry.ensure_deadline(0)
|
||||
else:
|
||||
msg = 'Username or password not set, authentication is not possible'
|
||||
logger.fatal(msg)
|
||||
raise exc or Etcd3Exception(msg)
|
||||
|
||||
try:
|
||||
return func(self, *args, **kwargs)
|
||||
except (UserEmpty, PermissionDenied) as e: # no token provided
|
||||
# PermissionDenied is raised on 3.0 and 3.1
|
||||
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied)
|
||||
or self._cluster_version < (3, 2)):
|
||||
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
|
||||
'supported on version lower than 3.3.0. Cluster version: '
|
||||
'{0}'.format('.'.join(map(str, self._cluster_version))))
|
||||
return retry(e)
|
||||
except InvalidAuthToken as e:
|
||||
logger.error('Invalid auth token: %s', self._token)
|
||||
return retry(e)
|
||||
try:
|
||||
return func(self, *args, retry=retry, **kwargs)
|
||||
except (UserEmpty, PermissionDenied) as e: # no token provided
|
||||
# PermissionDenied is raised on 3.0 and 3.1
|
||||
if self._cluster_version < (3, 3) and (not isinstance(e, PermissionDenied)
|
||||
or self._cluster_version < (3, 2)):
|
||||
raise UnsupportedEtcdVersion('Authentication is required by Etcd cluster but not '
|
||||
'supported on version lower than 3.3.0. Cluster version: '
|
||||
'{0}'.format('.'.join(map(str, self._cluster_version))))
|
||||
exc = e
|
||||
except InvalidAuthToken as e:
|
||||
logger.error('Invalid auth token: %s', self._token)
|
||||
exc = e
|
||||
except AuthOldRevision as e:
|
||||
logger.error('Auth token is for old revision of auth store')
|
||||
exc = e
|
||||
self._reauthenticate_reason = ReAuthenticateMode.WITHOUT_WATCHER_RESTART \
|
||||
if isinstance(exc, AuthOldRevision) else ReAuthenticateMode.REQUIRED
|
||||
if not retry:
|
||||
raise exc
|
||||
retry.ensure_deadline(0.5, exc)
|
||||
|
||||
@_handle_auth_errors
|
||||
def range(self, key: str, range_end: Union[bytes, str, None] = None, serializable: bool = True,
|
||||
retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
*, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
params = build_range_request(key, range_end)
|
||||
params['serializable'] = serializable # For better performance. We can tolerate stale reads
|
||||
return self.call_rpc('/kv/range', params, retry)
|
||||
|
||||
def prefix(self, key: str, serializable: bool = True, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
return self.range(key, prefix_range_end(key), serializable, retry)
|
||||
def prefix(self, key: str, serializable: bool = True, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
return self.range(key, prefix_range_end(key), serializable, retry=retry)
|
||||
|
||||
@_handle_auth_errors
|
||||
def lease_grant(self, ttl: int, retry: Optional[Retry] = None) -> str:
|
||||
def lease_grant(self, ttl: int, *, retry: Optional[Retry] = None) -> str:
|
||||
return self.call_rpc('/lease/grant', {'TTL': ttl}, retry)['ID']
|
||||
|
||||
def lease_keepalive(self, ID: str, retry: Optional[Retry] = None) -> Optional[str]:
|
||||
def lease_keepalive(self, ID: str, *, retry: Optional[Retry] = None) -> Optional[str]:
|
||||
return self.call_rpc('/lease/keepalive', {'ID': ID}, retry).get('result', {}).get('TTL')
|
||||
|
||||
@_handle_auth_errors
|
||||
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
|
||||
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
failure: Optional[Dict[str, Any]] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
fields = {'compare': [compare], 'success': [success]}
|
||||
if failure:
|
||||
fields['failure'] = [failure]
|
||||
@@ -352,7 +380,7 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
|
||||
@_handle_auth_errors
|
||||
def put(self, key: str, value: str, lease: Optional[str] = None, create_revision: Optional[str] = None,
|
||||
mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
mod_revision: Optional[str] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
fields = {'key': base64_encode(key), 'value': base64_encode(value)}
|
||||
if lease:
|
||||
fields['lease'] = lease
|
||||
@@ -367,14 +395,14 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
|
||||
|
||||
@_handle_auth_errors
|
||||
def deleterange(self, key: str, range_end: Union[bytes, str, None] = None,
|
||||
mod_revision: Optional[str] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
mod_revision: Optional[str] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
fields = build_range_request(key, range_end)
|
||||
if mod_revision is None:
|
||||
return self.call_rpc('/kv/deleterange', fields, retry)
|
||||
compare = {'target': 'MOD', 'mod_revision': mod_revision, 'key': fields['key']}
|
||||
return self.txn(compare, {'request_delete_range': fields}, retry=retry)
|
||||
|
||||
def deleteprefix(self, key: str, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
def deleteprefix(self, key: str, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
return self.deleterange(key, prefix_range_end(key), retry=retry)
|
||||
|
||||
def watchrange(self, key: str, range_end: Union[bytes, str, None] = None,
|
||||
@@ -574,9 +602,9 @@ class PatroniEtcd3Client(Etcd3Client):
|
||||
super(PatroniEtcd3Client, self).set_base_uri(value)
|
||||
self._restart_watcher()
|
||||
|
||||
def authenticate(self) -> bool:
|
||||
ret = super(PatroniEtcd3Client, self).authenticate()
|
||||
if ret:
|
||||
def authenticate(self, *, restart_watcher: bool = True, retry: Optional[Retry] = None) -> bool:
|
||||
ret = super(PatroniEtcd3Client, self).authenticate(restart_watcher=restart_watcher, retry=retry)
|
||||
if ret and restart_watcher:
|
||||
self._restart_watcher()
|
||||
return ret
|
||||
|
||||
@@ -631,8 +659,8 @@ class PatroniEtcd3Client(Etcd3Client):
|
||||
return ret
|
||||
|
||||
def txn(self, compare: Dict[str, Any], success: Dict[str, Any],
|
||||
failure: Optional[Dict[str, Any]] = None, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry)
|
||||
failure: Optional[Dict[str, Any]] = None, *, retry: Optional[Retry] = None) -> Dict[str, Any]:
|
||||
ret = super(PatroniEtcd3Client, self).txn(compare, success, failure, retry=retry)
|
||||
# Here we abuse the fact that the `failure` is only set in the call from update_leader().
|
||||
# In all other cases the txn() call failure may be an indicator of a stale cache,
|
||||
# and therefore we want to restart watcher.
|
||||
@@ -676,12 +704,12 @@ class Etcd3(AbstractEtcd):
|
||||
if not force and self._lease and self._last_lease_refresh + self._loop_wait > time.time():
|
||||
return False
|
||||
|
||||
if self._lease and not self._client.lease_keepalive(self._lease, retry):
|
||||
if self._lease and not self._client.lease_keepalive(self._lease, retry=retry):
|
||||
self._lease = None
|
||||
|
||||
ret = not self._lease
|
||||
if ret:
|
||||
self._lease = self._client.lease_grant(self._ttl, retry)
|
||||
self._lease = self._client.lease_grant(self._ttl, retry=retry)
|
||||
|
||||
self._last_lease_refresh = time.time()
|
||||
return ret
|
||||
@@ -723,23 +751,8 @@ class Etcd3(AbstractEtcd):
|
||||
history = history and TimelineHistory.from_node(history['mod_revision'], history['value'])
|
||||
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn or '')
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
|
||||
status = Status.from_node(status and status['value'])
|
||||
|
||||
# get list of members
|
||||
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -770,7 +783,7 @@ class Etcd3(AbstractEtcd):
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
nodes = {node['key'][len(path):]: node
|
||||
|
||||
+31
-21
@@ -19,7 +19,7 @@ from urllib3.exceptions import HTTPError
|
||||
from threading import Condition, Lock, Thread
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Type, Union, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, SyncState, \
|
||||
from . import AbstractDCS, Cluster, ClusterConfig, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, CITUS_COORDINATOR_GROUP_ID, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare, iter_response_objects, keepalive_socket_options, \
|
||||
@@ -771,8 +771,7 @@ class Kubernetes(AbstractDCS):
|
||||
except k8s_config.ConfigException:
|
||||
k8s_config.load_kube_config(context=config.get('context', 'kind-kind'))
|
||||
|
||||
pod_ip = config.get('pod_ip')
|
||||
self.__ips: List[str] = [] if self._ctl or not isinstance(pod_ip, str) else [pod_ip]
|
||||
self.__ips: List[str] = [] if self._ctl else [config.get('pod_ip', '')]
|
||||
self.__ports: List[K8sObject] = []
|
||||
ports: List[Dict[str, Any]] = config.get('ports', [{}])
|
||||
for p in ports:
|
||||
@@ -836,7 +835,7 @@ class Kubernetes(AbstractDCS):
|
||||
self._api.configure_timeouts(self.loop_wait, self._retry.deadline, self.ttl)
|
||||
|
||||
# retriable_http_codes supposed to be either int, list of integers or comma-separated string with integers.
|
||||
retriable_http_codes = config.get('retriable_http_codes', [])
|
||||
retriable_http_codes: Union[str, List[Union[str, int]]] = config.get('retriable_http_codes', [])
|
||||
if not isinstance(retriable_http_codes, list):
|
||||
retriable_http_codes = [c.strip() for c in str(retriable_http_codes).split(',')]
|
||||
|
||||
@@ -888,18 +887,8 @@ class Kubernetes(AbstractDCS):
|
||||
self._leader_resource_version = metadata.resource_version if metadata else None
|
||||
annotations: Dict[str, str] = metadata and metadata.annotations or {}
|
||||
|
||||
# get last known leader lsn
|
||||
try:
|
||||
last_lsn = int(annotations.get(self._OPTIME, ''))
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
# get permanent slots state (confirmed_flush_lsn)
|
||||
slots = annotations.get('slots')
|
||||
try:
|
||||
slots = json.loads(annotations.get('slots', ''))
|
||||
except Exception:
|
||||
slots = None
|
||||
# get last known leader lsn and slots
|
||||
status = Status.from_node(annotations)
|
||||
|
||||
# get failsafe topology
|
||||
try:
|
||||
@@ -945,7 +934,7 @@ class Kubernetes(AbstractDCS):
|
||||
metadata = sync and sync.metadata
|
||||
sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations)
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: Dict[str, Any]) -> Cluster:
|
||||
return self._cluster_from_nodes(path['group'], path['nodes'], path['pods'].values())
|
||||
@@ -1069,6 +1058,27 @@ class Kubernetes(AbstractDCS):
|
||||
def _patch_or_create(self, name: str, annotations: Dict[str, Any],
|
||||
resource_version: Optional[str] = None, patch: bool = False,
|
||||
retry: Optional[Callable[..., Any]] = None, ips: Optional[List[str]] = None) -> K8sObject:
|
||||
"""Patch or create K8s object, Endpoint or ConfigMap.
|
||||
|
||||
:param name: the name of the object.
|
||||
:param annotations: mapping of annotations that we want to create/update.
|
||||
:param resource_version: object should be updated only if the ``resource_version`` matches provided value.
|
||||
:param patch: ``True`` if we know in advance that the object already exists and we should patch it.
|
||||
:param retry: a callable that will take care of retries
|
||||
:param ips: IP address that we want to put to the subsets of the endpoint. Could have following values:
|
||||
|
||||
* ``None`` - when we don't need to touch subset;
|
||||
* ``[]`` - to set subsets to the empty list, when :meth:`delete_leader` method is called;
|
||||
|
||||
* ``['ip.add.re.ss']`` - when we want to make sure that the subsets of the leader endpoint
|
||||
contains the IP address of the leader, that we get from the ``kubernetes.pod_ip``;
|
||||
|
||||
* ``['']`` - when we want to make sure that the subsets of the leader endpoint contains the IP
|
||||
address of the leader, but ``kubernetes.pod_ip`` configuration is missing. In this case we will
|
||||
try to take the IP address of the Pod which name matches ``name`` from the config file.
|
||||
|
||||
:returns: the new :class:`V1Endpoints` or :class:`V1ConfigMap` object, that was created or updated.
|
||||
"""
|
||||
metadata = {'namespace': self._namespace, 'name': name, 'labels': self._labels, 'annotations': annotations}
|
||||
if patch or resource_version:
|
||||
if resource_version is not None:
|
||||
@@ -1081,9 +1091,10 @@ class Kubernetes(AbstractDCS):
|
||||
metadata['annotations'] = {k: v for k, v in annotations.items() if v is not None}
|
||||
|
||||
metadata = k8s_client.V1ObjectMeta(**metadata)
|
||||
if ips is not None and self._api.use_endpoints:
|
||||
if self._api.use_endpoints:
|
||||
endpoints = {'metadata': metadata}
|
||||
self._map_subsets(endpoints, ips)
|
||||
if ips is not None:
|
||||
self._map_subsets(endpoints, ips)
|
||||
body = k8s_client.V1Endpoints(**endpoints)
|
||||
else:
|
||||
body = k8s_client.V1ConfigMap(metadata=metadata)
|
||||
@@ -1232,11 +1243,10 @@ class Kubernetes(AbstractDCS):
|
||||
else:
|
||||
annotations['acquireTime'] = self._leader_observed_record.get('acquireTime') or now
|
||||
annotations['transitions'] = str(transitions)
|
||||
ips: Optional[List[str]] = [] if self._api.use_endpoints else None
|
||||
|
||||
try:
|
||||
ret = bool(self._patch_or_create(self.leader_path, annotations,
|
||||
self._leader_resource_version, retry=self.retry, ips=ips))
|
||||
self._leader_resource_version, retry=self.retry, ips=self.__ips))
|
||||
except k8s_client.rest.ApiException as e:
|
||||
if e.status == 409 and self._leader_resource_version: # Conflict in resource_version
|
||||
# Terminate watchers, it could be a sign that K8s API is in a failed state
|
||||
|
||||
+5
-19
@@ -12,7 +12,8 @@ from pysyncobj.transport import TCPTransport, CONNECTION_STATE
|
||||
from pysyncobj.utility import TcpUtility
|
||||
from typing import Any, Callable, Collection, Dict, List, Optional, Set, Union, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import validate_directory
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -343,23 +344,8 @@ class Raft(AbstractDCS):
|
||||
history = history and TimelineHistory.from_node(history['index'], history['value'])
|
||||
|
||||
# get last know leader lsn and slots
|
||||
status = nodes.get(self._STATUS)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status['value'])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = nodes.get(self._LEADER_OPTIME)
|
||||
last_lsn = last_lsn and last_lsn['value']
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn or '')
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
status = nodes.get(self._STATUS) or nodes.get(self._LEADER_OPTIME)
|
||||
status = Status.from_node(status and status['value'])
|
||||
|
||||
# get list of members
|
||||
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
|
||||
@@ -387,7 +373,7 @@ class Raft(AbstractDCS):
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
response = self._sync_obj.get(path, recursive=True)
|
||||
|
||||
+47
-108
@@ -12,7 +12,8 @@ from kazoo.retry import RetryFailedError
|
||||
from kazoo.security import ACL, make_acl
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, TYPE_CHECKING
|
||||
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory, citus_group_re
|
||||
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, Status, SyncState, \
|
||||
TimelineHistory, citus_group_re
|
||||
from ..exceptions import DCSError
|
||||
from ..utils import deep_compare
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -89,7 +90,7 @@ class ZooKeeper(AbstractDCS):
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super(ZooKeeper, self).__init__(config)
|
||||
|
||||
hosts = config.get('hosts', [])
|
||||
hosts: Union[str, List[str]] = config.get('hosts', [])
|
||||
if isinstance(hosts, list):
|
||||
hosts = ','.join(hosts)
|
||||
|
||||
@@ -115,10 +116,7 @@ class ZooKeeper(AbstractDCS):
|
||||
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), **kwargs)
|
||||
self._client.add_listener(self.session_listener)
|
||||
|
||||
self._fetch_cluster: bool = True
|
||||
self._fetch_status: bool = True
|
||||
self.__last_member_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
self._orig_kazoo_connect = self._client._connection._connect
|
||||
@@ -141,18 +139,9 @@ class ZooKeeper(AbstractDCS):
|
||||
ret = self._orig_kazoo_connect(*args)
|
||||
return max(self.loop_wait - 2, 2) * 1000, ret[1]
|
||||
|
||||
def session_listener(self, state: str) -> None:
|
||||
if state in [KazooState.SUSPENDED, KazooState.LOST]:
|
||||
self.cluster_watcher(None)
|
||||
|
||||
def status_watcher(self, event: Optional[WatchedEvent]) -> None:
|
||||
self._fetch_status = True
|
||||
self.event.set()
|
||||
|
||||
def cluster_watcher(self, event: Optional[WatchedEvent]) -> None:
|
||||
self._fetch_cluster = True
|
||||
if not event or event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
|
||||
self.status_watcher(event)
|
||||
def _watcher(self, event: WatchedEvent) -> None:
|
||||
if event.state != KazooState.CONNECTED or event.path.startswith(self.client_path('')):
|
||||
self.event.set()
|
||||
|
||||
def reload_config(self, config: Union['Config', Dict[str, Any]]) -> None:
|
||||
self.set_retry_timeout(config['retry_timeout'])
|
||||
@@ -200,138 +189,89 @@ class ZooKeeper(AbstractDCS):
|
||||
except NoNodeError:
|
||||
return None
|
||||
|
||||
def get_status(self, path: str, leader: Optional[Leader]) -> Tuple[int, Optional[Dict[str, int]]]:
|
||||
watch = self.status_watcher if not leader or leader.name != self._name else None
|
||||
|
||||
status = self.get_node(path + self._STATUS, watch)
|
||||
if status:
|
||||
try:
|
||||
status = json.loads(status[0])
|
||||
last_lsn = status.get(self._OPTIME)
|
||||
slots = status.get('slots')
|
||||
except Exception:
|
||||
slots = last_lsn = None
|
||||
else:
|
||||
last_lsn = self.get_node(path + self._LEADER_OPTIME, watch)
|
||||
last_lsn = last_lsn and last_lsn[0]
|
||||
slots = None
|
||||
|
||||
try:
|
||||
last_lsn = int(last_lsn or '')
|
||||
except Exception:
|
||||
last_lsn = 0
|
||||
|
||||
self._fetch_status = False
|
||||
return last_lsn, slots
|
||||
def get_status(self, path: str, leader: Optional[Leader]) -> Status:
|
||||
status = self.get_node(path + self._STATUS)
|
||||
if not status:
|
||||
status = self.get_node(path + self._LEADER_OPTIME)
|
||||
return Status.from_node(status and status[0])
|
||||
|
||||
@staticmethod
|
||||
def member(name: str, value: str, znode: ZnodeStat) -> Member:
|
||||
return Member.from_node(znode.version, name, znode.ephemeralOwner, value)
|
||||
|
||||
def get_children(self, key: str, watch: Optional[Callable[[WatchedEvent], None]] = None) -> List[str]:
|
||||
def get_children(self, key: str) -> List[str]:
|
||||
try:
|
||||
return self._client.get_children(key, watch)
|
||||
return self._client.get_children(key)
|
||||
except NoNodeError:
|
||||
return []
|
||||
|
||||
def load_members(self, path: str) -> List[Member]:
|
||||
members: List[Member] = []
|
||||
for member in self.get_children(path + self._MEMBERS, self.cluster_watcher):
|
||||
for member in self.get_children(path + self._MEMBERS):
|
||||
data = self.get_node(path + self._MEMBERS + member)
|
||||
if data is not None:
|
||||
members.append(self.member(member, *data))
|
||||
return members
|
||||
|
||||
def _cluster_loader(self, path: str) -> Cluster:
|
||||
self._fetch_cluster = False
|
||||
self.event.clear()
|
||||
nodes = set(self.get_children(path, self.cluster_watcher))
|
||||
if not nodes:
|
||||
self._fetch_cluster = True
|
||||
nodes = set(self.get_children(path))
|
||||
|
||||
# get initialize flag
|
||||
initialize = (self.get_node(path + self._INITIALIZE) or [None])[0] if self._INITIALIZE in nodes else None
|
||||
|
||||
# get global dynamic configuration
|
||||
config = self.get_node(path + self._CONFIG, watch=self.cluster_watcher) if self._CONFIG in nodes else None
|
||||
config = self.get_node(path + self._CONFIG, watch=self._watcher) if self._CONFIG in nodes else None
|
||||
config = config and ClusterConfig.from_node(config[1].version, config[0], config[1].mzxid)
|
||||
|
||||
# get timeline history
|
||||
history = self.get_node(path + self._HISTORY, watch=self.cluster_watcher) if self._HISTORY in nodes else None
|
||||
history = self.get_node(path + self._HISTORY) if self._HISTORY in nodes else None
|
||||
history = history and TimelineHistory.from_node(history[1].mzxid, history[0])
|
||||
|
||||
# get synchronization state
|
||||
sync = self.get_node(path + self._SYNC, watch=self.cluster_watcher) if self._SYNC in nodes else None
|
||||
sync = self.get_node(path + self._SYNC) if self._SYNC in nodes else None
|
||||
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
|
||||
|
||||
# get list of members
|
||||
members = self.load_members(path) if self._MEMBERS[:-1] in nodes else []
|
||||
|
||||
# get leader
|
||||
leader = self.get_node(path + self._LEADER) if self._LEADER in nodes else None
|
||||
leader = self.get_node(path + self._LEADER, watch=self._watcher) if self._LEADER in nodes else None
|
||||
if leader:
|
||||
member = Member(-1, leader[0], None, {})
|
||||
member = ([m for m in members if m.name == leader[0]] or [member])[0]
|
||||
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
|
||||
self._fetch_cluster = member.version == -1
|
||||
|
||||
# get last known leader lsn and slots
|
||||
last_lsn, slots = self.get_status(path, leader)
|
||||
status = self.get_status(path, leader)
|
||||
|
||||
# failover key
|
||||
failover = self.get_node(path + self._FAILOVER, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
|
||||
failover = self.get_node(path + self._FAILOVER) if self._FAILOVER in nodes else None
|
||||
failover = failover and Failover.from_node(failover[1].version, failover[0])
|
||||
|
||||
# get failsafe topology
|
||||
failsafe = self.get_node(path + self._FAILSAFE, watch=self.cluster_watcher) if self._FAILSAFE in nodes else None
|
||||
failsafe = self.get_node(path + self._FAILSAFE) if self._FAILSAFE in nodes else None
|
||||
try:
|
||||
failsafe = json.loads(failsafe[0]) if failsafe else None
|
||||
except Exception:
|
||||
failsafe = None
|
||||
|
||||
return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots, failsafe)
|
||||
return Cluster(initialize, config, leader, status, members, failover, sync, history, failsafe)
|
||||
|
||||
def _citus_cluster_loader(self, path: str) -> Dict[int, Cluster]:
|
||||
fetch_cluster = False
|
||||
ret: Dict[int, Cluster] = {}
|
||||
for node in self.get_children(path, self.cluster_watcher):
|
||||
for node in self.get_children(path):
|
||||
if citus_group_re.match(node):
|
||||
ret[int(node)] = self._cluster_loader(path + node + '/')
|
||||
fetch_cluster = fetch_cluster or self._fetch_cluster
|
||||
self._fetch_cluster = fetch_cluster
|
||||
return ret
|
||||
|
||||
def _load_cluster(
|
||||
self, path: str, loader: Callable[[str], Union[Cluster, Dict[int, Cluster]]]
|
||||
) -> Union[Cluster, Dict[int, Cluster]]:
|
||||
cluster = self.cluster if path == self._base_path + '/' else None
|
||||
if self._fetch_cluster or cluster is None:
|
||||
try:
|
||||
cluster = self._client.retry(loader, path)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
self.cluster_watcher(None)
|
||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||
# The /status ZNode was updated or doesn't exist
|
||||
elif self._fetch_status and not self._fetch_cluster or not cluster.last_lsn \
|
||||
or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots:
|
||||
# If current node is the leader just clear the event without fetching anything (we are updating the /status)
|
||||
if cluster.leader and cluster.leader.name == self._name:
|
||||
self.event.clear()
|
||||
else:
|
||||
try:
|
||||
last_lsn, slots = self.get_status(self.client_path(''), cluster.leader)
|
||||
self.event.clear()
|
||||
new_cluster: List[Any] = list(cluster)
|
||||
new_cluster[3] = last_lsn
|
||||
new_cluster[8] = slots
|
||||
cluster = Cluster(*new_cluster)
|
||||
except Exception:
|
||||
pass
|
||||
return cluster
|
||||
|
||||
def _bypass_caches(self) -> None:
|
||||
self._fetch_cluster = True
|
||||
try:
|
||||
return self._client.retry(loader, path)
|
||||
except Exception:
|
||||
logger.exception('get_cluster')
|
||||
raise ZooKeeperError('ZooKeeper in not responding properly')
|
||||
|
||||
def _create(self, path: str, value: bytes, retry: bool = False, ephemeral: bool = False) -> bool:
|
||||
try:
|
||||
@@ -393,21 +333,17 @@ class ZooKeeper(AbstractDCS):
|
||||
cluster = self.cluster
|
||||
member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
|
||||
member_data = self.__last_member_data or member and member.data
|
||||
# We want to notify leader if some important fields in the member key changed by removing ZNode
|
||||
if member and (self._client.client_id is not None and member.session != self._client.client_id[0]
|
||||
or not (member_data and deep_compare(member_data.get('tags', {}), data.get('tags', {}))
|
||||
and (member_data.get('state') == data.get('state')
|
||||
or 'running' not in (member_data.get('state'), data.get('state')))
|
||||
and member_data.get('version') == data.get('version')
|
||||
and member_data.get('checkpoint_after_promote')
|
||||
== data.get('checkpoint_after_promote'))):
|
||||
try:
|
||||
self._client.delete_async(self.member_path).get(timeout=1)
|
||||
except NoNodeError:
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
member = None
|
||||
if member and member_data:
|
||||
# We want delete the member ZNode if our session doesn't match with session id on our member key
|
||||
if self._client.client_id is not None and member.session != self._client.client_id[0]:
|
||||
logger.warning('Recreating the member ZNode due to ownership mismatch')
|
||||
try:
|
||||
self._client.delete_async(self.member_path).get(timeout=1)
|
||||
except NoNodeError:
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
member = None
|
||||
|
||||
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
|
||||
if member and member_data:
|
||||
@@ -499,7 +435,10 @@ class ZooKeeper(AbstractDCS):
|
||||
return self.set_sync_state_value("{}", version) is not False
|
||||
|
||||
def watch(self, leader_version: Optional[int], timeout: float) -> bool:
|
||||
ret = super(ZooKeeper, self).watch(leader_version, timeout + 0.5)
|
||||
if ret and not self._fetch_status:
|
||||
self._fetch_cluster = True
|
||||
return ret or self._fetch_cluster
|
||||
if leader_version:
|
||||
timeout += 0.5
|
||||
|
||||
try:
|
||||
return super(ZooKeeper, self).watch(leader_version, timeout)
|
||||
finally:
|
||||
self.event.clear()
|
||||
|
||||
+91
-35
@@ -14,7 +14,7 @@ from . import psycopg
|
||||
from .__main__ import Patroni
|
||||
from .async_executor import AsyncExecutor, CriticalTask
|
||||
from .collections import CaseInsensitiveSet
|
||||
from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember
|
||||
from .dcs import AbstractDCS, Cluster, Leader, Member, RemoteMember, Status, slot_name_from_member_name
|
||||
from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException
|
||||
from .postgresql.callback_executor import CallbackAction
|
||||
from .postgresql.misc import postgres_version_to_int
|
||||
@@ -123,7 +123,8 @@ class Failsafe(object):
|
||||
leader = self.leader
|
||||
if leader:
|
||||
# We rely on the strict order of fields in the namedtuple
|
||||
cluster = Cluster(*cluster[0:2], leader, *cluster[3:8], leader.member.data['slots'], *cluster[9:])
|
||||
status = Status(cluster.status.last_lsn, leader.member.data['slots'])
|
||||
cluster = Cluster(*cluster[0:2], leader, status, *cluster[4:])
|
||||
return cluster
|
||||
|
||||
def is_active(self) -> bool:
|
||||
@@ -234,7 +235,7 @@ class Ha(object):
|
||||
"""
|
||||
if not self.cluster.failover:
|
||||
return 'failover'
|
||||
return 'switchover' if self.cluster.failover.is_switchover else 'manual failover'
|
||||
return 'switchover' if self.cluster.failover.leader else 'manual failover'
|
||||
|
||||
def load_cluster_from_dcs(self) -> None:
|
||||
cluster = self.dcs.get_cluster()
|
||||
@@ -271,12 +272,32 @@ class Ha(object):
|
||||
ret[self.state_handler.name] = self.patroni.api.connection_string
|
||||
return ret
|
||||
|
||||
def update_lock(self, write_leader_optime: bool = False) -> bool:
|
||||
def update_lock(self, update_status: bool = False) -> bool:
|
||||
"""Update the leader lock in DCS.
|
||||
|
||||
.. note::
|
||||
After successful update of the leader key the :meth:`AbstractDCS.update_leader` method could also
|
||||
optionally update the ``/status`` and ``/failsafe`` keys.
|
||||
|
||||
The ``/status`` key contains the last known LSN on the leader node and the last known state
|
||||
of permanent replication slots including permanent physical replication slot for the leader.
|
||||
|
||||
Last, but not least, this method calls a :meth:`Watchdog.keepalive` method after the leader key
|
||||
was successfully updated.
|
||||
|
||||
:param update_status: ``True`` if we also need to update the ``/status`` key in DCS, otherwise ``False``.
|
||||
|
||||
:returns: ``True`` if the leader key was successfully updated and we can continue to run postgres
|
||||
as a ``primary`` or as a ``standby_leader``, otherwise ``False``.
|
||||
"""
|
||||
last_lsn = slots = None
|
||||
if write_leader_optime:
|
||||
if update_status:
|
||||
try:
|
||||
last_lsn = self.state_handler.last_operation()
|
||||
slots = self.state_handler.slots()
|
||||
slots = self.cluster.filter_permanent_slots(
|
||||
{**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
|
||||
@@ -583,7 +604,9 @@ class Ha(object):
|
||||
"""
|
||||
# The standby leader or when there is no standby leader we want to follow
|
||||
# the remote member, except when there is no standby leader in pause.
|
||||
if self.is_standby_cluster() and (self.has_lock(False) or self.cluster.is_unlocked() and not self.is_paused()):
|
||||
if self.is_standby_cluster() \
|
||||
and (cluster.leader and cluster.leader.name and cluster.leader.name == self.state_handler.name
|
||||
or cluster.is_unlocked() and not self.is_paused()):
|
||||
node_to_follow = self.get_remote_member()
|
||||
# If replicatefrom tag is set, try to follow the node mentioned there, otherwise, follow the leader.
|
||||
elif self.patroni.replicatefrom and self.patroni.replicatefrom != self.state_handler.name:
|
||||
@@ -690,6 +713,14 @@ class Ha(object):
|
||||
current = CaseInsensitiveSet(sync.members)
|
||||
picked, allow_promote = self.state_handler.sync_handler.current_state(self.cluster)
|
||||
|
||||
if picked == current and current != allow_promote:
|
||||
logger.warning('Inconsistent state between synchronous_standby_names = %s and /sync = %s key '
|
||||
'detected, updating synchronous replication key...', list(allow_promote), list(current))
|
||||
sync = self.dcs.write_sync_state(self.state_handler.name, allow_promote, version=sync.version)
|
||||
if not sync:
|
||||
return logger.warning("Updating sync state failed")
|
||||
current = CaseInsensitiveSet(sync.members)
|
||||
|
||||
if picked != current:
|
||||
# update synchronous standby list in dcs temporarily to point to common nodes in current and picked
|
||||
sync_common = current & allow_promote
|
||||
@@ -771,13 +802,13 @@ class Ha(object):
|
||||
if cluster_history:
|
||||
self.dcs.set_history_value('[]')
|
||||
elif not cluster_history or cluster_history[-1][0] != primary_timeline - 1 or len(cluster_history[-1]) != 5:
|
||||
cluster_history = {line[0]: line for line in cluster_history}
|
||||
cluster_history_dict: Dict[int, List[Any]] = {line[0]: list(line) for line in cluster_history}
|
||||
history: List[List[Any]] = list(map(list, self.state_handler.get_history(primary_timeline)))
|
||||
if self.cluster.config:
|
||||
history = history[-self.cluster.config.max_timelines_history:]
|
||||
for line in history:
|
||||
# enrich current history with promotion timestamps stored in DCS
|
||||
cluster_history_line = list(cluster_history.get(line[0], []))
|
||||
cluster_history_line = cluster_history_dict.get(line[0], [])
|
||||
if len(line) == 3 and len(cluster_history_line) >= 4 and cluster_history_line[1] == line[1]:
|
||||
line.append(cluster_history_line[3])
|
||||
if len(cluster_history_line) == 5:
|
||||
@@ -889,6 +920,26 @@ class Ha(object):
|
||||
return False
|
||||
|
||||
def check_failsafe_topology(self) -> bool:
|
||||
"""Check whether we could continue to run as a primary by calling all members from the failsafe topology.
|
||||
|
||||
.. note::
|
||||
If the ``/failsafe`` key contains invalid data or if the ``name`` of our node is missing in
|
||||
the ``/failsafe`` key, we immediately give up and return ``False``.
|
||||
|
||||
We send the JSON document in the POST request with the following fields:
|
||||
|
||||
* ``name`` - the name of our node;
|
||||
* ``conn_url`` - connection URL to the postgres, which is reachable from other nodes;
|
||||
* ``api_url`` - connection URL to Patroni REST API on this node reachable from other nodes;
|
||||
* ``slots`` - a :class:`dict` with replication slots that exist on the leader node, including the primary
|
||||
itself with the last known LSN, because there could be a permanent physical slot on standby nodes.
|
||||
|
||||
Standby nodes are using information from the ``slots`` dict to advance position of permanent
|
||||
replication slots while DCS is not accessible in order to avoid indefinite growth of ``pg_wal``.
|
||||
|
||||
:returns: ``True`` if all members from the ``/failsafe`` topology agree that this node could continue to
|
||||
run as a ``primary``, or ``False`` if some of standby nodes are not accessible or don't agree.
|
||||
"""
|
||||
failsafe = self.dcs.failsafe
|
||||
if not isinstance(failsafe, dict) or self.state_handler.name not in failsafe:
|
||||
return False
|
||||
@@ -898,7 +949,10 @@ class Ha(object):
|
||||
'api_url': self.patroni.api.connection_string,
|
||||
}
|
||||
try:
|
||||
data['slots'] = self.state_handler.slots()
|
||||
data['slots'] = {
|
||||
**self.state_handler.slots(),
|
||||
slot_name_from_member_name(self.state_handler.name): self.state_handler.last_operation()
|
||||
}
|
||||
except Exception:
|
||||
logger.exception('Exception when called state_handler.slots()')
|
||||
members = [RemoteMember(name, {'api_url': url})
|
||||
@@ -922,27 +976,6 @@ class Ha(object):
|
||||
lag = (self.cluster.last_lsn or 0) - wal_position
|
||||
return lag > self.global_config.maximum_lag_on_failover
|
||||
|
||||
def has_members_eligible_to_promote(self, members: List[Member], reference_lsn: int = 0,
|
||||
fast_path: bool = False) -> bool:
|
||||
ret = False
|
||||
cluster_timeline = self.cluster.timeline
|
||||
|
||||
for st in self.fetch_nodes_statuses(members):
|
||||
not_allowed_reason = st.failover_limitation()
|
||||
if not_allowed_reason:
|
||||
logger.info('Member %s is %s', st.member.name, not_allowed_reason)
|
||||
elif fast_path:
|
||||
return True
|
||||
elif reference_lsn and st.wal_position < reference_lsn or \
|
||||
not reference_lsn and self.is_lagging(st.wal_position):
|
||||
logger.info('Member %s exceeds maximum replication lag', st.member.name)
|
||||
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline):
|
||||
logger.info('Timeline %s of member %s is behind the cluster timeline %s',
|
||||
st.timeline, st.member.name, cluster_timeline)
|
||||
else:
|
||||
ret = True
|
||||
return ret
|
||||
|
||||
def _is_healthiest_node(self, members: Collection[Member], check_replication_lag: bool = True) -> bool:
|
||||
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
|
||||
|
||||
@@ -976,6 +1009,15 @@ class Ha(object):
|
||||
if not self.sync_mode_is_active() or not self.cluster.sync.leader_matches(st.member.name):
|
||||
return False
|
||||
logger.info('Ignoring the former leader being ahead of us')
|
||||
if my_wal_position == st.wal_position and self.patroni.failover_priority < st.failover_priority:
|
||||
# There's a higher priority non-lagging replica
|
||||
logger.info(
|
||||
'%s has equally tolerable WAL position and priority %s, while this node has priority %s',
|
||||
st.member.name,
|
||||
st.failover_priority,
|
||||
self.patroni.failover_priority,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_failover_possible(self, *, cluster_lsn: int = 0, exclude_failover_candidate: bool = False) -> bool:
|
||||
@@ -995,7 +1037,21 @@ class Ha(object):
|
||||
elif not candidates:
|
||||
logger.warning('%s: candidates list is empty', action)
|
||||
|
||||
return self.has_members_eligible_to_promote(candidates, cluster_lsn)
|
||||
ret = False
|
||||
cluster_timeline = self.cluster.timeline
|
||||
for st in self.fetch_nodes_statuses(candidates):
|
||||
not_allowed_reason = st.failover_limitation()
|
||||
if not_allowed_reason:
|
||||
logger.info('Member %s is %s', st.member.name, not_allowed_reason)
|
||||
elif cluster_lsn and st.wal_position < cluster_lsn or \
|
||||
not cluster_lsn and self.is_lagging(st.wal_position):
|
||||
logger.info('Member %s exceeds maximum replication lag', st.member.name)
|
||||
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline):
|
||||
logger.info('Timeline %s of member %s is behind the cluster timeline %s',
|
||||
st.timeline, st.member.name, cluster_timeline)
|
||||
else:
|
||||
ret = True
|
||||
return ret
|
||||
|
||||
def manual_failover_process_no_leader(self) -> Optional[bool]:
|
||||
"""Handles manual failover/switchover when the old leader already stepped down.
|
||||
@@ -1044,7 +1100,7 @@ class Ha(object):
|
||||
return False
|
||||
|
||||
# try to pick some other members for switchover and check that they are healthy
|
||||
if failover.is_switchover:
|
||||
if failover.leader:
|
||||
if self.state_handler.name == failover.leader: # I was the leader
|
||||
# exclude desired member which is unhealthy if it was specified
|
||||
if self.is_failover_possible(exclude_failover_candidate=bool(failover.candidate)):
|
||||
@@ -1101,7 +1157,7 @@ class Ha(object):
|
||||
|
||||
if self.cluster.failover:
|
||||
# When doing a switchover in synchronous mode only synchronous nodes and former leader are allowed to race
|
||||
if self.cluster.failover.is_switchover and self.sync_mode_is_active() \
|
||||
if self.cluster.failover.leader and self.sync_mode_is_active() \
|
||||
and not self.cluster.sync.matches(self.state_handler.name, True):
|
||||
return False
|
||||
return self.manual_failover_process_no_leader() or False
|
||||
@@ -2022,7 +2078,7 @@ class Ha(object):
|
||||
def is_eligible(node: Member) -> bool:
|
||||
# in synchronous mode we allow failover (not switchover!) to async node
|
||||
if self.sync_mode_is_active() and not self.cluster.sync.matches(node.name)\
|
||||
and not (failover and failover.is_failover):
|
||||
and not (failover and not failover.leader):
|
||||
return False
|
||||
# Don't spend time on "nofailover" nodes checking.
|
||||
# We also don't need nodes which we can't query with the api in the list.
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
import datetime
|
||||
|
||||
from .dcs import Cluster
|
||||
from .ha import Patroni
|
||||
from .utils import ParseScheduleErrors
|
||||
|
||||
from .utils import parse_schedule
|
||||
|
||||
|
||||
class ManualFailoverPrecheckStatus(Enum):
|
||||
FAILOVER_NO_CANDIDATE = ('Failover could be performed only to a specific candidate', 400)
|
||||
SWITCHOVER_NO_LEADER = ('Switchover could be performed only from a specific leader', 400)
|
||||
SCHEDULED_FAILOVER = ("Failover can't be scheduled", 400)
|
||||
SCHEDULED_SWITCHOVER_PAUSE = ("Can't schedule switchover in the paused state", 400)
|
||||
SWITCHOVER_PAUSE_NO_CANDIDATE = ('Switchover is possible only to a specific candidate in a paused state', 400)
|
||||
SWITCHOVER_TO_LEADER = ('Switchover target and source are the same', 400)
|
||||
|
||||
CLUSTER_NO_LEADER = ('Cluster {cluster_name} has no leader', 412)
|
||||
LEADER_NOT_MEMBER = ('Member {leader} is not the leader of cluster {cluster_name}', 412)
|
||||
CANDIDATE_NOT_SYNC_STANDBY = ('candidate name does not match with sync_standby', 412)
|
||||
NO_SYNC_CANDIDATE = ('{action} is not possible: can not find sync_standby', 412)
|
||||
ONLY_LEADER = ('{action} is not possible: cluster does not have members except leader', 412)
|
||||
CANDIDATE_NOT_MEMEBER = ('Member {candidate} does not exist in cluster {cluster_name} or is tagged as nofailover',
|
||||
412)
|
||||
NO_GOOD_CANDIDATES = ('{action} is not possible: no good candidates have been found', 412)
|
||||
|
||||
CHECK_PASSED = ('', None)
|
||||
|
||||
|
||||
class ManualFailover(object):
|
||||
|
||||
def __init__(self, action: str, cluster: 'Cluster',
|
||||
leader: Optional[str], candidate: Optional[str], scheduled: Optional[str],
|
||||
paused: bool = False, sync_mode: bool = False, patroni_obj: Optional['Patroni'] = None) -> None:
|
||||
self.action = action
|
||||
self.cluster = cluster
|
||||
self.leader = leader
|
||||
self.candidate = candidate
|
||||
self.scheduled = scheduled
|
||||
self.paused = paused
|
||||
self.sync_mode = sync_mode
|
||||
self.patroni = patroni_obj
|
||||
|
||||
def parse_scheduled(self) -> Tuple[Optional['ParseScheduleErrors'], Optional['datetime.datetime']]:
|
||||
return parse_schedule(self.scheduled)
|
||||
|
||||
def run_precheck(self) -> ManualFailoverPrecheckStatus:
|
||||
if self.action == 'failover' and not self.candidate:
|
||||
return ManualFailoverPrecheckStatus.FAILOVER_NO_CANDIDATE
|
||||
elif self.action == 'switchover' and not self.leader:
|
||||
return ManualFailoverPrecheckStatus.SWITCHOVER_NO_LEADER
|
||||
|
||||
if self.scheduled:
|
||||
if self.action == 'failover':
|
||||
return ManualFailoverPrecheckStatus.SCHEDULED_FAILOVER
|
||||
elif self.paused:
|
||||
return ManualFailoverPrecheckStatus.SCHEDULED_SWITCHOVER_PAUSE
|
||||
|
||||
if self.paused and not self.candidate:
|
||||
return ManualFailoverPrecheckStatus.SWITCHOVER_PAUSE_NO_CANDIDATE
|
||||
|
||||
if self.leader == self.candidate:
|
||||
return ManualFailoverPrecheckStatus.SWITCHOVER_TO_LEADER
|
||||
|
||||
if self.action == 'switchover':
|
||||
if self.cluster.leader is None or not self.cluster.leader.name:
|
||||
return ManualFailoverPrecheckStatus.CLUSTER_NO_LEADER
|
||||
if self.cluster.leader.name != self.leader:
|
||||
return ManualFailoverPrecheckStatus.LEADER_NOT_MEMBER
|
||||
|
||||
if self.candidate:
|
||||
if self.action == 'switchover' and self.sync_mode and not self.cluster.sync.matches(self.candidate):
|
||||
return ManualFailoverPrecheckStatus.CANDIDATE_NOT_SYNC_STANDBY
|
||||
members = [m for m in self.cluster.members if m.name == self.candidate]
|
||||
if not members:
|
||||
return ManualFailoverPrecheckStatus.CANDIDATE_NOT_MEMEBER
|
||||
elif self.sync_mode:
|
||||
members = [m for m in self.cluster.members if self.cluster.sync.matches(m.name)]
|
||||
if not members:
|
||||
return ManualFailoverPrecheckStatus.NO_SYNC_CANDIDATE
|
||||
else:
|
||||
members = [m for m in self.cluster.members if not self.cluster.leader or m.name != self.cluster.leader.name and m.api_url]
|
||||
if not members:
|
||||
return ManualFailoverPrecheckStatus.ONLY_LEADER
|
||||
|
||||
if self.patroni and not self.patroni.ha.has_members_eligible_to_promote(members, fast_path=True):
|
||||
return ManualFailoverPrecheckStatus.NO_GOOD_CANDIDATES
|
||||
|
||||
return ManualFailoverPrecheckStatus.CHECK_PASSED
|
||||
@@ -27,7 +27,7 @@ from .sync import SyncHandler
|
||||
from .. import psycopg
|
||||
from ..async_executor import CriticalTask
|
||||
from ..collections import CaseInsensitiveSet
|
||||
from ..dcs import Cluster, Leader, Member
|
||||
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
|
||||
|
||||
@@ -112,24 +112,35 @@ class Postgresql(object):
|
||||
self._state_entry_timestamp = 0
|
||||
|
||||
self._cluster_info_state = {}
|
||||
self._has_permanent_logical_slots = True
|
||||
self._has_permanent_slots = True
|
||||
self._enforce_hot_standby_feedback = False
|
||||
self._cached_replica_timeline = None
|
||||
|
||||
# Last known running process
|
||||
self._postmaster_proc = None
|
||||
|
||||
if self.is_running(): # we are "joining" already running postgres
|
||||
self.set_state('running')
|
||||
if self.is_running():
|
||||
# If we found postmaster process we need to figure out whether postgres is accepting connections
|
||||
self.set_state('starting')
|
||||
self.check_startup_state_changed()
|
||||
|
||||
if self.state == 'running': # we are "joining" already running postgres
|
||||
# we know that PostgreSQL is accepting connections and can read some GUC's from pg_settings
|
||||
self.config.load_current_server_parameters()
|
||||
|
||||
self.set_role('master' if self.is_primary() else 'replica')
|
||||
# postpone writing postgresql.conf for 12+ because recovery parameters are not yet known
|
||||
if self.major_version < 120000 or self.is_primary():
|
||||
self.config.write_postgresql_conf()
|
||||
|
||||
hba_saved = self.config.replace_pg_hba()
|
||||
ident_saved = self.config.replace_pg_ident()
|
||||
if hba_saved or ident_saved:
|
||||
|
||||
if self.major_version < 120000 or self.role in ('master', 'primary'):
|
||||
# If PostgreSQL is running as a primary or we run PostgreSQL that is older than 12 we can
|
||||
# call reload_config() once again (the first call happened in the ConfigHandler constructor),
|
||||
# so that it can figure out if config files should be updated and pg_ctl reload executed.
|
||||
self.config.reload_config(config, sighup=bool(hba_saved or ident_saved))
|
||||
elif hba_saved or ident_saved:
|
||||
self.reload()
|
||||
elif self.role in ('master', 'primary'):
|
||||
elif not self.is_running() and self.role in ('master', 'primary'):
|
||||
self.set_role('demoted')
|
||||
|
||||
@property
|
||||
@@ -174,6 +185,11 @@ class Postgresql(object):
|
||||
""":returns: `True` if Postgres version supports more than one synchronous node."""
|
||||
return self._major_version >= 90600
|
||||
|
||||
@property
|
||||
def can_advance_slots(self) -> bool:
|
||||
"""``True`` if :attr:``major_version`` is greater than 110000."""
|
||||
return self.major_version >= SLOT_ADVANCE_AVAILABLE_VERSION
|
||||
|
||||
@property
|
||||
def cluster_info_query(self) -> str:
|
||||
"""Returns the monitoring query with a fixed number of fields.
|
||||
@@ -208,8 +224,9 @@ class Postgresql(object):
|
||||
extra = ("pg_catalog.current_setting('restore_command')" if self._major_version >= 120000 else "NULL") +\
|
||||
", " + ("(SELECT pg_catalog.json_agg(s.*) FROM (SELECT slot_name, slot_type as type, datoid::bigint, "
|
||||
"plugin, catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"
|
||||
" AS confirmed_flush_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"
|
||||
if self._has_permanent_logical_slots and self._major_version >= 110000 else "NULL") + extra
|
||||
" AS confirmed_flush_lsn, pg_catalog.pg_wal_lsn_diff(restart_lsn, '0/0')::bigint"
|
||||
" AS restart_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"
|
||||
if self._has_permanent_slots and self.can_advance_slots else "NULL") + extra
|
||||
extra = (", CASE WHEN latest_end_lsn IS NULL THEN NULL ELSE received_tli END,"
|
||||
" slot_name, conninfo, status, {0} FROM pg_catalog.pg_stat_get_wal_receiver()").format(extra)
|
||||
if self.role == 'standby_leader':
|
||||
@@ -435,17 +452,20 @@ class Postgresql(object):
|
||||
|
||||
if self._global_config.is_standby_cluster:
|
||||
# Standby cluster can't have logical replication slots, and we don't need to enforce hot_standby_feedback
|
||||
self._has_permanent_logical_slots = False
|
||||
self.set_enforce_hot_standby_feedback(False)
|
||||
elif cluster and cluster.config and cluster.config.modify_version:
|
||||
self._has_permanent_logical_slots =\
|
||||
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version)
|
||||
|
||||
if cluster and cluster.config and cluster.config.modify_version:
|
||||
# We want to enable hot_standby_feedback if the replica is supposed
|
||||
# to have a logical slot or in case if it is the cascading replica.
|
||||
self.set_enforce_hot_standby_feedback(
|
||||
self._has_permanent_logical_slots
|
||||
or cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
|
||||
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:
|
||||
@@ -456,7 +476,7 @@ class Postgresql(object):
|
||||
'received_tli', 'slot_name', 'conninfo', 'receiver_state',
|
||||
'restore_command', 'slots', 'synchronous_commit',
|
||||
'synchronous_standby_names', 'pg_stat_replication'], result))
|
||||
if self._has_permanent_logical_slots:
|
||||
if self._has_permanent_slots and self.can_advance_slots:
|
||||
cluster_info_state['slots'] =\
|
||||
self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
|
||||
self._cluster_info_state = cluster_info_state
|
||||
@@ -568,7 +588,7 @@ class Postgresql(object):
|
||||
r'lsn: ([0-9A-Fa-f]+/[0-9A-Fa-f]+), prev ([0-9A-Fa-f]+/[0-9A-Fa-f]+), '
|
||||
r'.*?desc: (.+)', out.decode('utf-8'))
|
||||
if match:
|
||||
return match.groups()
|
||||
return match.group(1), match.group(2), match.group(3), match.group(4)
|
||||
return None, None, None, None
|
||||
|
||||
def latest_checkpoint_location(self) -> Optional[int]:
|
||||
@@ -1023,7 +1043,7 @@ class Postgresql(object):
|
||||
return None, None
|
||||
|
||||
@contextmanager
|
||||
def get_replication_connection_cursor(self, host: Optional[str] = None, port: int = 5432,
|
||||
def get_replication_connection_cursor(self, host: Optional[str] = None, port: Union[int, str] = 5432,
|
||||
**kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
conn_kwargs = self.config.replication.copy()
|
||||
conn_kwargs.update(host=host, port=int(port) if port else None, user=conn_kwargs.pop('username'),
|
||||
|
||||
@@ -151,9 +151,47 @@ class Bootstrap(object):
|
||||
os.unlink(trigger_file)
|
||||
|
||||
def _custom_bootstrap(self, config: Any) -> bool:
|
||||
"""Bootstrap a fresh Patroni cluster using a custom method provided by the user.
|
||||
|
||||
:param config: configuration used for running a custom bootstrap method. It comes from the Patroni YAML file,
|
||||
so it is expected to be a :class:`dict`.
|
||||
|
||||
.. note::
|
||||
*config* must contain a ``command`` key, which value is the command or script to perform the custom
|
||||
bootstrap procedure. The exit code of the ``command`` dictates if the bootstrap succeeded or failed.
|
||||
|
||||
When calling ``command``, Patroni will pass the following arguments to the ``command`` call:
|
||||
|
||||
* ``--scope``: contains the value of ``scope`` configuration;
|
||||
* ``--data_dir``: contains the value of the ``postgresql.data_dir`` configuration.
|
||||
|
||||
You can avoid that behavior by filling the optional key ``no_params`` with the value ``False`` in the
|
||||
configuration file, which will instruct Patroni to not pass these parameters to the ``command`` call.
|
||||
|
||||
Besides that, a couple more keys are supported in *config*, but optional:
|
||||
|
||||
* ``keep_existing_recovery_conf``: if ``True``, instruct Patroni to not remove the existing
|
||||
``recovery.conf`` (PostgreSQL <= 11), to not discard recovery parameters from the configuration
|
||||
(PostgreSQL >= 12), and to not remove the files ``recovery.signal`` or ``standby.signal``
|
||||
(PostgreSQL >= 12). This is specially useful when you are restoring backups through tools like
|
||||
pgBackRest and Barman, in which case they generated the appropriate recovery settings for you;
|
||||
* ``recovery_conf``: a section containing a map, where each key is the name of a recovery related
|
||||
setting, and the value is the value of the corresponding setting.
|
||||
|
||||
Any key/value other than the ones that were described above will be interpreted as additional arguments for
|
||||
the ``command`` call. They will all be added to the call in the format ``--key=value``.
|
||||
|
||||
:returns: ``True`` if the bootstrap was successful, i.e. the execution of the custom ``command`` from *config*
|
||||
exited with code ``0``, ``False`` otherwise.
|
||||
"""
|
||||
self._postgresql.set_state('running custom bootstrap script')
|
||||
params = [] if config.get('no_params') else ['--scope=' + self._postgresql.scope,
|
||||
'--datadir=' + self._postgresql.data_dir]
|
||||
# Add custom parameters specified by the user
|
||||
reserved_args = {'no_params', 'keep_existing_recovery_conf', 'recovery_conf', 'scope', 'datadir'}
|
||||
for arg, val in config.items():
|
||||
if arg not in reserved_args:
|
||||
params.append(f"--{arg}={val}")
|
||||
try:
|
||||
logger.info('Running custom bootstrap script: %s', config['command'])
|
||||
if self._postgresql.cancellable.call(shlex.split(config['command']) + params) != 0:
|
||||
@@ -400,6 +438,9 @@ BEGIN
|
||||
END;$$""".format(f, quote_ident(rewind['username'], postgresql.connection()))
|
||||
postgresql.query(sql)
|
||||
|
||||
if config.get('users'):
|
||||
logger.warning('User creation via "bootstrap.users" will be removed in v4.0.0')
|
||||
|
||||
for name, value in (config.get('users') or {}).items():
|
||||
if all(name != a.get('username') for a in (superuser, replication, rewind)):
|
||||
self.create_or_update_role(name, value.get('password'), value.get('options', []))
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from enum import Enum
|
||||
from threading import Condition, Thread
|
||||
from typing import List
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .cancellable import CancellableExecutor, CancellableSubprocess
|
||||
|
||||
@@ -30,7 +31,9 @@ class OnReloadExecutor(CancellableSubprocess):
|
||||
self.cancel(kill=True)
|
||||
self._kill_children()
|
||||
with self._lock:
|
||||
self._start_process(cmd, close_fds=True)
|
||||
started = self._start_process(cmd, close_fds=True)
|
||||
if started and self._process is not None:
|
||||
Thread(target=self._process.wait).start()
|
||||
|
||||
|
||||
class CallbackExecutor(CancellableExecutor, Thread):
|
||||
@@ -51,6 +54,8 @@ class CallbackExecutor(CancellableExecutor, Thread):
|
||||
If it couldn't be killed we wait until it finishes.
|
||||
|
||||
:param cmd: command to be executed"""
|
||||
kwargs: Dict[str, Any] = {'stacklevel': 3} if sys.version_info >= (3, 8) else {}
|
||||
logger.debug('CallbackExecutor.call(%s)', cmd, **kwargs)
|
||||
|
||||
if cmd[-3] == CallbackAction.ON_RELOAD:
|
||||
return self._on_reload_executor.call_nowait(cmd)
|
||||
|
||||
@@ -397,12 +397,15 @@ class CitusHandler(Thread):
|
||||
parameters['shared_preload_libraries'] = ','.join(['citus'] + shared_preload_libraries)
|
||||
|
||||
# if not explicitly set Citus overrides max_prepared_transactions to max_connections*2
|
||||
if parameters.get('max_prepared_transactions') == 0:
|
||||
if parameters['max_prepared_transactions'] == 0:
|
||||
parameters['max_prepared_transactions'] = parameters['max_connections'] * 2
|
||||
|
||||
# Resharding in Citus implemented using logical replication
|
||||
parameters['wal_level'] = 'logical'
|
||||
|
||||
# Sometimes Citus needs to connect to the local postgres. We will do it the same way as Patroni does.
|
||||
parameters['citus.local_hostname'] = self._postgresql.connection_pool.conn_kwargs.get('host', 'localhost')
|
||||
|
||||
def ignore_replication_slot(self, slot: Dict[str, str]) -> bool:
|
||||
if isinstance(self._config, dict) and self._postgresql.is_primary() and\
|
||||
slot['type'] == 'logical' and slot['database'] == self._config['database']:
|
||||
|
||||
@@ -244,9 +244,10 @@ class ConfigWriter(object):
|
||||
self._fd.write(line)
|
||||
self._fd.write('\n')
|
||||
|
||||
def writelines(self, lines: List[str]) -> None:
|
||||
def writelines(self, lines: List[Optional[str]]) -> None:
|
||||
for line in lines:
|
||||
self.writeline(line)
|
||||
if isinstance(line, str):
|
||||
self.writeline(line)
|
||||
|
||||
@staticmethod
|
||||
def escape(value: Any) -> str: # Escape (by doubling) any single quotes or backslashes in given string
|
||||
@@ -326,14 +327,22 @@ class ConfigHandler(object):
|
||||
.format(self._pgpass))
|
||||
self._passfile = None
|
||||
self._passfile_mtime = None
|
||||
self._synchronous_standby_names = None
|
||||
self._postmaster_ctime = None
|
||||
self._current_recovery_params: Optional[CaseInsensitiveDict] = None
|
||||
self._config = {}
|
||||
self._recovery_params = CaseInsensitiveDict()
|
||||
self._server_parameters: CaseInsensitiveDict
|
||||
self._server_parameters: CaseInsensitiveDict = CaseInsensitiveDict()
|
||||
self.reload_config(config)
|
||||
|
||||
def load_current_server_parameters(self) -> None:
|
||||
"""Read GUC's values from ``pg_settings`` when Patroni is joining the the postgres that is already running."""
|
||||
exclude = [name.lower() for name, value in self.CMDLINE_OPTIONS.items() if value[1] == _false_validator] \
|
||||
+ [name.lower() for name in self._RECOVERY_PARAMETERS]
|
||||
self._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)})
|
||||
|
||||
def setup_server_parameters(self) -> None:
|
||||
self._server_parameters = self.get_server_parameters(self._config)
|
||||
self._adjust_recovery_parameters()
|
||||
@@ -922,14 +931,15 @@ class ConfigHandler(object):
|
||||
listen_addresses, port = split_host_port(config['listen'], 5432)
|
||||
parameters.update(cluster_name=self._postgresql.scope, listen_addresses=listen_addresses, port=str(port))
|
||||
if not self._postgresql.global_config or self._postgresql.global_config.is_synchronous_mode:
|
||||
if self._synchronous_standby_names is None:
|
||||
synchronous_standby_names = self._server_parameters.get('synchronous_standby_names')
|
||||
if synchronous_standby_names is None:
|
||||
if self._postgresql.global_config and self._postgresql.global_config.is_synchronous_mode_strict\
|
||||
and self._postgresql.role in ('master', 'primary', 'promoted'):
|
||||
parameters['synchronous_standby_names'] = '*'
|
||||
else:
|
||||
parameters.pop('synchronous_standby_names', None)
|
||||
else:
|
||||
parameters['synchronous_standby_names'] = self._synchronous_standby_names
|
||||
parameters['synchronous_standby_names'] = synchronous_standby_names
|
||||
|
||||
# Handle hot_standby <-> replica rename
|
||||
if parameters.get('wal_level') == ('hot_standby' if self._postgresql.major_version >= 90600 else 'replica'):
|
||||
@@ -1026,17 +1036,14 @@ class ConfigHandler(object):
|
||||
# "notify" connection_pool about the "new" local connection address
|
||||
self._postgresql.connection_pool.conn_kwargs = local_conn_kwargs
|
||||
|
||||
def _get_pg_settings(
|
||||
self, names: Collection[str]
|
||||
) -> Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]]:
|
||||
def _get_pg_settings(self, names: Collection[str]) -> Dict[Any, Tuple[Any, ...]]:
|
||||
return {r[0]: r for r in self._postgresql.query(('SELECT name, setting, unit, vartype, context, sourcefile'
|
||||
+ ' FROM pg_catalog.pg_settings '
|
||||
+ ' WHERE pg_catalog.lower(name) = ANY(%s)'),
|
||||
[n.lower() for n in names])}
|
||||
|
||||
@staticmethod
|
||||
def _handle_wal_buffers(old_values: Dict[str, Tuple[str, str, Optional[str], str, str, Optional[str]]],
|
||||
changes: CaseInsensitiveDict) -> None:
|
||||
def _handle_wal_buffers(old_values: Dict[Any, Tuple[Any, ...]], changes: CaseInsensitiveDict) -> None:
|
||||
wal_block_size = parse_int(old_values['wal_block_size'][1]) or 8192
|
||||
wal_segment_size = old_values['wal_segment_size']
|
||||
wal_segment_unit = parse_int(wal_segment_size[2], 'B') or 8192 \
|
||||
@@ -1153,12 +1160,11 @@ class ConfigHandler(object):
|
||||
def set_synchronous_standby_names(self, value: Optional[str]) -> Optional[bool]:
|
||||
"""Updates synchronous_standby_names and reloads if necessary.
|
||||
:returns: True if value was updated."""
|
||||
if value != self._synchronous_standby_names:
|
||||
if value != self._server_parameters.get('synchronous_standby_names'):
|
||||
if value is None:
|
||||
self._server_parameters.pop('synchronous_standby_names', None)
|
||||
else:
|
||||
self._server_parameters['synchronous_standby_names'] = value
|
||||
self._synchronous_standby_names = value
|
||||
if self._postgresql.state == 'running':
|
||||
self.write_postgresql_conf()
|
||||
self._postgresql.reload()
|
||||
|
||||
@@ -158,7 +158,7 @@ class Rewind(object):
|
||||
def _get_local_timeline_lsn(self) -> Tuple[Optional[bool], Optional[int], Optional[int]]:
|
||||
if self._postgresql.is_running(): # if postgres is running - get timeline from replication connection
|
||||
in_recovery = True
|
||||
timeline = self._postgresql.received_timeline() or self._postgresql.get_replica_timeline()
|
||||
timeline = self._postgresql.get_replica_timeline()
|
||||
lsn = self._postgresql.replayed_location()
|
||||
else: # otherwise analyze pg_controldata output
|
||||
in_recovery, timeline, lsn = self._get_local_timeline_lsn_from_controldata()
|
||||
|
||||
+38
-25
@@ -231,15 +231,16 @@ class SlotsHandler:
|
||||
ret: Dict[str, int] = {}
|
||||
|
||||
slots_dict: Dict[str, Dict[str, Any]] = {slot['slot_name']: slot for slot in slots or []}
|
||||
if slots_dict:
|
||||
for name, value in slots_dict.items():
|
||||
if name in self._replication_slots:
|
||||
if compare_slots(value, self._replication_slots[name], 'datoid'):
|
||||
if value['type'] == 'logical':
|
||||
ret[name] = value['confirmed_flush_lsn']
|
||||
self._copy_items(value, self._replication_slots[name])
|
||||
for name, value in slots_dict.items():
|
||||
if name in self._replication_slots:
|
||||
if compare_slots(value, self._replication_slots[name], 'datoid'):
|
||||
if value['type'] == 'logical':
|
||||
ret[name] = value['confirmed_flush_lsn']
|
||||
self._copy_items(value, self._replication_slots[name])
|
||||
else:
|
||||
self._schedule_load_slots = True
|
||||
self._replication_slots[name]['restart_lsn'] = ret[name] = value['restart_lsn']
|
||||
else:
|
||||
self._schedule_load_slots = True
|
||||
|
||||
# It could happen that the slot was deleted in the background, we want to detect this case
|
||||
if any(name not in slots_dict for name in self._replication_slots.keys()):
|
||||
@@ -260,16 +261,19 @@ class SlotsHandler:
|
||||
"""
|
||||
if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
|
||||
replication_slots: Dict[str, Dict[str, Any]] = {}
|
||||
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint" \
|
||||
pg_wal_lsn_diff = f"pg_catalog.pg_{self._postgresql.wal_name}_{self._postgresql.lsn_name}_diff"
|
||||
extra = f", catalog_xmin, {pg_wal_lsn_diff}(confirmed_flush_lsn, '0/0')::bigint" \
|
||||
if self._postgresql.major_version >= 100000 else ""
|
||||
skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else ''
|
||||
for r in self._query('SELECT slot_name, slot_type, plugin, database, datoid'
|
||||
f'{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}'):
|
||||
for r in self._query(f"SELECT slot_name, slot_type, {pg_wal_lsn_diff}(restart_lsn, '0/0')::bigint, plugin,"
|
||||
f" database, datoid{extra} FROM pg_catalog.pg_replication_slots{skip_temp_slots}"):
|
||||
value = {'type': r[1]}
|
||||
if r[1] == 'logical':
|
||||
value.update(plugin=r[2], database=r[3], datoid=r[4])
|
||||
value.update(plugin=r[3], database=r[4], datoid=r[5])
|
||||
if self._postgresql.major_version >= 100000:
|
||||
value.update(catalog_xmin=r[5], confirmed_flush_lsn=r[6])
|
||||
value.update(catalog_xmin=r[6], confirmed_flush_lsn=r[7])
|
||||
else:
|
||||
value['restart_lsn'] = r[2]
|
||||
replication_slots[r[0]] = value
|
||||
self._replication_slots = replication_slots
|
||||
self._schedule_load_slots = False
|
||||
@@ -313,7 +317,7 @@ class SlotsHandler:
|
||||
' true AS dropped FROM slots WHERE not active) '
|
||||
'SELECT active, COALESCE(dropped, false) FROM slots'
|
||||
' FULL OUTER JOIN dropped ON true'), name)
|
||||
return rows[0] if rows else (False, False)
|
||||
return (rows[0][0], rows[0][1]) if rows else (False, False)
|
||||
|
||||
def _drop_incorrect_slots(self, cluster: Cluster, slots: Dict[str, Any], paused: bool) -> None:
|
||||
"""Compare required slots and configured as permanent slots with those found, dropping extraneous ones.
|
||||
@@ -353,7 +357,7 @@ class SlotsHandler:
|
||||
self._schedule_load_slots = True
|
||||
|
||||
def _ensure_physical_slots(self, slots: Dict[str, Any]) -> None:
|
||||
"""Create any missing physical replication *slots*.
|
||||
"""Create or advance physical replication *slots*.
|
||||
|
||||
Any failures are logged and do not interrupt creation of all *slots*.
|
||||
|
||||
@@ -362,7 +366,9 @@ class SlotsHandler:
|
||||
"""
|
||||
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
|
||||
for name, value in slots.items():
|
||||
if name not in self._replication_slots and value['type'] == 'physical':
|
||||
if value['type'] != 'physical':
|
||||
continue
|
||||
if name not in self._replication_slots:
|
||||
try:
|
||||
self._query(f"SELECT pg_catalog.pg_create_physical_replication_slot(%s{immediately_reserve})"
|
||||
f" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots"
|
||||
@@ -371,6 +377,15 @@ class SlotsHandler:
|
||||
except Exception:
|
||||
logger.exception("Failed to create physical replication slot '%s'", name)
|
||||
self._schedule_load_slots = True
|
||||
elif self._postgresql.can_advance_slots and self._replication_slots[name]['type'] == 'physical':
|
||||
value['restart_lsn'] = self._replication_slots[name]['restart_lsn']
|
||||
lsn = value.get('lsn')
|
||||
if lsn and lsn > value['restart_lsn']: # The slot has feedback in DCS and needs to be advanced
|
||||
try:
|
||||
lsn = format_lsn(lsn)
|
||||
self._query("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", name, lsn)
|
||||
except Exception as exc:
|
||||
logger.error("Error while advancing replication slot %s to position '%s': %r", name, lsn, exc)
|
||||
|
||||
@contextmanager
|
||||
def get_local_connection_cursor(self, **kwargs: Any) -> Iterator[Union['cursor', 'Cursor[Any]']]:
|
||||
@@ -460,12 +475,9 @@ class SlotsHandler:
|
||||
# If the logical already exists, copy some information about it into the original structure
|
||||
if name in self._replication_slots and compare_slots(value, self._replication_slots[name]):
|
||||
self._copy_items(self._replication_slots[name], value)
|
||||
if 'lsn' in value: # The slot has feedback in DCS
|
||||
try: # Skip slots that don't need to be advanced
|
||||
if value['confirmed_flush_lsn'] < int(value['lsn']):
|
||||
advance_slots[value['database']][name] = int(value['lsn'])
|
||||
except Exception as e:
|
||||
logger.error('Failed to parse "%s": %r', value['lsn'], e)
|
||||
if 'lsn' in value and value['confirmed_flush_lsn'] < value['lsn']: # The slot has feedback in DCS
|
||||
# Skip slots that don't need to be advanced
|
||||
advance_slots[value['database']][name] = value['lsn']
|
||||
elif name not in self._replication_slots and 'lsn' in value:
|
||||
# We want to copy only slots with feedback in a DCS
|
||||
create_slots.append(name)
|
||||
@@ -484,10 +496,11 @@ class SlotsHandler:
|
||||
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 found on the primary, then compare to those configured in the DCS.
|
||||
Read physical and logical slots from ``pg_replication_slots``, then compare to those configured in the DCS.
|
||||
Drop any slots that do not match those required by configuration and are not configured as permanent.
|
||||
Create any missing physical slots. If we are the leader then logical slots too, otherwise if logical slots
|
||||
are known and active create them on replica nodes.
|
||||
Create any missing physical slots, or advance their position according to feedback stored in DCS.
|
||||
If we are the primary then create logical slots, otherwise if logical slots are known and active create
|
||||
them on replica nodes by copying slot files from the primary.
|
||||
|
||||
:param cluster: object containing stateful information for the cluster.
|
||||
:param nofailover: ``True`` if this node has been tagged to not be a failover candidate.
|
||||
|
||||
+25
-2
@@ -3,6 +3,8 @@ import abc
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from patroni.utils import parse_int
|
||||
|
||||
|
||||
class Tags(abc.ABC):
|
||||
"""An abstract class that encapsulates all the ``tags`` logic.
|
||||
@@ -45,8 +47,29 @@ class Tags(abc.ABC):
|
||||
|
||||
@property
|
||||
def nofailover(self) -> bool:
|
||||
"""``True`` if ``nofailover`` is ``True``, else ``False``."""
|
||||
return bool(self.tags.get('nofailover', False))
|
||||
"""Common logic for obtaining the value of ``nofailover`` from ``tags`` if defined.
|
||||
|
||||
If ``nofailover`` is not defined, this methods returns ``True`` if ``failover_priority`` is non-positive,
|
||||
``False`` otherwise.
|
||||
"""
|
||||
from_tags = self.tags.get('nofailover')
|
||||
if from_tags is not None:
|
||||
# Value of `nofailover` takes precedence over `failover_priority`
|
||||
return bool(from_tags)
|
||||
failover_priority = parse_int(self.tags.get('failover_priority'))
|
||||
return failover_priority is not None and failover_priority <= 0
|
||||
|
||||
@property
|
||||
def failover_priority(self) -> int:
|
||||
"""Common logic for obtaining the value of ``failover_priority`` from ``tags`` if defined.
|
||||
|
||||
If ``nofailover`` is defined as ``True``, this will return ``0``. Otherwise, it will return the value of
|
||||
``failover_priority``, defaulting to ``1`` if it's not defined or invalid.
|
||||
"""
|
||||
from_tags = self.tags.get('nofailover')
|
||||
failover_priority = parse_int(self.tags.get('failover_priority'))
|
||||
failover_priority = 1 if failover_priority is None else failover_priority
|
||||
return 0 if from_tags else failover_priority
|
||||
|
||||
@property
|
||||
def noloadbalance(self) -> bool:
|
||||
|
||||
+3
-27
@@ -9,8 +9,6 @@
|
||||
:var DBL_RE: regular expression to match double precision numbers, signed or unsigned. Matches scientific notation too.
|
||||
:var WHITESPACE_RE: regular expression to match whitespace characters
|
||||
"""
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
@@ -22,7 +20,6 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from enum import Enum
|
||||
from shlex import split
|
||||
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Union, Tuple, Type, TYPE_CHECKING
|
||||
@@ -822,7 +819,7 @@ def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig']
|
||||
member.update({n: m.data[n] for n in optional_attributes if n in m.data})
|
||||
|
||||
if m.name != leader_name:
|
||||
lsn = m.data.get('xlog_location')
|
||||
lsn = m.lsn
|
||||
if lsn is None:
|
||||
member['lag'] = 'unknown'
|
||||
elif cluster_lsn >= lsn:
|
||||
@@ -839,9 +836,8 @@ def cluster_as_json(cluster: 'Cluster', global_config: Optional['GlobalConfig']
|
||||
ret['pause'] = True
|
||||
if cluster.failover and cluster.failover.scheduled_at:
|
||||
ret['scheduled_switchover'] = {'at': cluster.failover.scheduled_at.isoformat()}
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert cluster.failover.leader
|
||||
ret['scheduled_switchover']['from'] = cluster.failover.leader
|
||||
if cluster.failover.leader:
|
||||
ret['scheduled_switchover']['from'] = cluster.failover.leader
|
||||
if cluster.failover.candidate:
|
||||
ret['scheduled_switchover']['to'] = cluster.failover.candidate
|
||||
return ret
|
||||
@@ -1065,23 +1061,3 @@ def get_major_version(bin_dir: Optional[str] = None, bin_name: str = 'postgres')
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
assert version is not None
|
||||
return '.'.join([version.group(1), version.group(3)]) if int(version.group(1)) < 10 else version.group(1)
|
||||
|
||||
|
||||
class ParseScheduleErrors(Enum):
|
||||
NO_TIMEZONE = ('Timezone information is mandatory for the scheduled {action}', 400)
|
||||
SCHEDULED_IN_PAST = ('Cannot schedule {action} in the past', 422)
|
||||
PARSING_ERROR = ('Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601', 422)
|
||||
|
||||
|
||||
def parse_schedule(schedule: Optional[str]) -> Tuple[Optional[ParseScheduleErrors], Optional[datetime.datetime]]:
|
||||
scheduled_at = None
|
||||
if schedule is not None:
|
||||
try:
|
||||
scheduled_at = dateutil.parser.parse(schedule)
|
||||
if scheduled_at.tzinfo is None:
|
||||
return ParseScheduleErrors.NO_TIMEZONE, scheduled_at
|
||||
elif scheduled_at < datetime.datetime.now(tzutc):
|
||||
return ParseScheduleErrors.SCHEDULED_IN_PAST, scheduled_at
|
||||
except (ValueError, TypeError):
|
||||
return ParseScheduleErrors.PARSING_ERROR, scheduled_at
|
||||
return None, scheduled_at
|
||||
|
||||
+83
-37
@@ -379,6 +379,37 @@ class Or(object):
|
||||
self.args = args
|
||||
|
||||
|
||||
class AtMostOne(object):
|
||||
"""Mark that at most one option from a :class:`Case` can be suplied.
|
||||
|
||||
Represents a list of possible configuration options in a given scope, where at most one can actually
|
||||
be provided.
|
||||
|
||||
.. note::
|
||||
|
||||
It should be used together with a :class:`Case` object.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: str) -> None:
|
||||
"""Create a :class`AtMostOne` object.
|
||||
|
||||
:param `*args`: any arguments that the caller wants to be stored in this :class:`Or` object.
|
||||
|
||||
:Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
AtMostOne("nofailover", "failover_priority"): Case({
|
||||
"nofailover": bool,
|
||||
"failover_priority": IntValidator(min=0, raise_assert=True),
|
||||
})
|
||||
|
||||
The :class`AtMostOne` object is used to define that at most one of ``nofailover`` and
|
||||
``failover_priority`` can be provided.
|
||||
"""
|
||||
self.args = args
|
||||
|
||||
|
||||
class Optional(object):
|
||||
"""Mark a configuration option as optional.
|
||||
|
||||
@@ -671,6 +702,9 @@ class Schema(object):
|
||||
# One key in `validator` attribute (`key` variable) can be mapped to one or more keys in `data` attribute (`d`
|
||||
# variable), depending on the `key` type.
|
||||
for key in self.validator.keys():
|
||||
if isinstance(key, AtMostOne) and len(list(self._data_key(key))) > 1:
|
||||
yield Result(False, f"Multiple of {key.args} provided")
|
||||
continue
|
||||
for d in self._data_key(key):
|
||||
if d not in self.data and not isinstance(key, Optional):
|
||||
yield Result(False, "is not defined.", path=d)
|
||||
@@ -680,7 +714,7 @@ class Schema(object):
|
||||
if d not in self.data and isinstance(key, Optional):
|
||||
self.data[d] = key.default
|
||||
validator = self.validator[key]
|
||||
if isinstance(key, Or) and isinstance(self.validator[key], Case):
|
||||
if isinstance(key, (Or, AtMostOne)) and isinstance(self.validator[key], Case):
|
||||
validator = self.validator[key]._schema[d]
|
||||
# In this loop we may be calling a new `Schema` either over an intermediate node in the tree, or
|
||||
# over a leaf node. In the latter case the recursive calls in the given path will finish.
|
||||
@@ -715,7 +749,7 @@ class Schema(object):
|
||||
max_level = v.level
|
||||
yield Result(v.status, v.error, path=v.path, level=v.level, data=v.data)
|
||||
|
||||
def _data_key(self, key: Union[str, Optional, Or]) -> Iterator[str]:
|
||||
def _data_key(self, key: Union[str, Optional, Or, AtMostOne]) -> Iterator[str]:
|
||||
"""Map a key from the ``validator`` dictionary to the corresponding key(s) in the ``data`` dictionary.
|
||||
|
||||
:param key: key from the ``validator`` attribute.
|
||||
@@ -735,15 +769,23 @@ class Schema(object):
|
||||
elif isinstance(key, Or):
|
||||
# At least one of the `Or` entries should be available in the `data` dictionary. If we find at least one of
|
||||
# them in `data`, then we return all found entries so the caller method can validate them all.
|
||||
if any([i in self.data for i in key.args]):
|
||||
for i in key.args:
|
||||
if i in self.data:
|
||||
yield i
|
||||
if any([item in self.data for item in key.args]):
|
||||
for item in key.args:
|
||||
if item in self.data:
|
||||
yield item
|
||||
# If none of the `Or` entries is available in the `data` dictionary, then we return all entries so the
|
||||
# caller method will issue errors that they are all absent.
|
||||
else:
|
||||
for i in key.args:
|
||||
yield i
|
||||
for item in key.args:
|
||||
yield item
|
||||
# If the key was defined as a `AtMostOne` object in `validator` attribute, then each of its values
|
||||
# are the keys to access the `data` dictionary.
|
||||
elif isinstance(key, AtMostOne):
|
||||
# Yield back all of the entries from the `data` dictionary, each will be validated and then counted
|
||||
# to inform us if we've provided too many
|
||||
for item in key.args:
|
||||
if item in self.data:
|
||||
yield item
|
||||
|
||||
|
||||
def _get_type_name(python_type: Any) -> str:
|
||||
@@ -772,27 +814,28 @@ def assert_(condition: bool, message: str = "Wrong value") -> None:
|
||||
class IntValidator(object):
|
||||
"""Validate an integer setting.
|
||||
|
||||
:cvar expected_type: the expected Python type for an integer setting (:class:`int`).
|
||||
:ivar min: minimum allowed value for the setting, if any.
|
||||
:ivar max: maximum allowed value for the setting, if any.
|
||||
:ivar base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
|
||||
:ivar expected_type: the expected Python type.
|
||||
:ivar raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
|
||||
"""
|
||||
|
||||
expected_type = int
|
||||
|
||||
def __init__(self, min: OptionalType[int] = None, max: OptionalType[int] = None,
|
||||
base_unit: OptionalType[str] = None, raise_assert: bool = False) -> None:
|
||||
base_unit: OptionalType[str] = None, expected_type: Any = None, raise_assert: bool = False) -> None:
|
||||
"""Create an :class:`IntValidator` object with the given rules.
|
||||
|
||||
:param min: minimum allowed value for the setting, if any.
|
||||
:param max: maximum allowed value for the setting, if any.
|
||||
:param base_unit: the base unit to convert the value to before checking if it's within *min* and *max* range.
|
||||
:param expected_type: the expected Python type.
|
||||
:param raise_assert: if an ``assert`` test should be performed regarding expected type and valid range.
|
||||
"""
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.base_unit = base_unit
|
||||
if expected_type:
|
||||
self.expected_type = expected_type
|
||||
self.raise_assert = raise_assert
|
||||
|
||||
def __call__(self, value: Any) -> bool:
|
||||
@@ -911,36 +954,36 @@ schema = Schema({
|
||||
Optional("allowlist_include_members"): bool,
|
||||
Optional("http_extra_headers"): dict,
|
||||
Optional("https_extra_headers"): dict,
|
||||
Optional("request_queue_size"): IntValidator(min=0, max=4096, raise_assert=True)
|
||||
Optional("request_queue_size"): IntValidator(min=0, max=4096, expected_type=int, raise_assert=True)
|
||||
},
|
||||
Optional("bootstrap"): {
|
||||
"dcs": {
|
||||
Optional("ttl"): int,
|
||||
Optional("loop_wait"): int,
|
||||
Optional("retry_timeout"): int,
|
||||
Optional("maximum_lag_on_failover"): int,
|
||||
Optional("maximum_lag_on_syncnode"): int,
|
||||
Optional("ttl"): IntValidator(min=20, raise_assert=True),
|
||||
Optional("loop_wait"): IntValidator(min=1, raise_assert=True),
|
||||
Optional("retry_timeout"): IntValidator(min=3, raise_assert=True),
|
||||
Optional("maximum_lag_on_failover"): IntValidator(min=0, raise_assert=True),
|
||||
Optional("maximum_lag_on_syncnode"): IntValidator(min=-1, raise_assert=True),
|
||||
Optional("postgresql"): {
|
||||
Optional("parameters"): {
|
||||
Optional("max_connections"): int,
|
||||
Optional("max_locks_per_transaction"): int,
|
||||
Optional("max_prepared_transactions"): int,
|
||||
Optional("max_replication_slots"): int,
|
||||
Optional("max_wal_senders"): int,
|
||||
Optional("max_worker_processes"): int
|
||||
Optional("max_connections"): IntValidator(1, 262143, raise_assert=True),
|
||||
Optional("max_locks_per_transaction"): IntValidator(10, 2147483647, raise_assert=True),
|
||||
Optional("max_prepared_transactions"): IntValidator(0, 262143, raise_assert=True),
|
||||
Optional("max_replication_slots"): IntValidator(0, 262143, raise_assert=True),
|
||||
Optional("max_wal_senders"): IntValidator(0, 262143, raise_assert=True),
|
||||
Optional("max_worker_processes"): IntValidator(0, 262143, raise_assert=True),
|
||||
},
|
||||
Optional("use_pg_rewind"): bool,
|
||||
Optional("pg_hba"): [str],
|
||||
Optional("pg_ident"): [str],
|
||||
Optional("pg_ctl_timeout"): int,
|
||||
Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True),
|
||||
Optional("use_slots"): bool,
|
||||
},
|
||||
Optional("primary_start_timeout"): int,
|
||||
Optional("primary_stop_timeout"): int,
|
||||
Optional("primary_start_timeout"): IntValidator(min=0, raise_assert=True),
|
||||
Optional("primary_stop_timeout"): IntValidator(min=0, raise_assert=True),
|
||||
Optional("standby_cluster"): {
|
||||
Or("host", "port", "restore_command"): Case({
|
||||
"host": str,
|
||||
"port": int,
|
||||
"port": IntValidator(max=65535, expected_type=int, raise_assert=True),
|
||||
"restore_command": str
|
||||
}),
|
||||
Optional("primary_slot_name"): str,
|
||||
@@ -950,7 +993,7 @@ schema = Schema({
|
||||
},
|
||||
Optional("synchronous_mode"): bool,
|
||||
Optional("synchronous_mode_strict"): bool,
|
||||
Optional("synchronous_node_count"): int
|
||||
Optional("synchronous_node_count"): IntValidator(min=1, raise_assert=True),
|
||||
},
|
||||
Optional("initdb"): [Or(str, dict)],
|
||||
Optional("method"): str
|
||||
@@ -961,7 +1004,7 @@ schema = Schema({
|
||||
"host": validate_host_port,
|
||||
"url": str
|
||||
}),
|
||||
Optional("port"): int,
|
||||
Optional("port"): IntValidator(max=65535, expected_type=int, raise_assert=True),
|
||||
Optional("scheme"): str,
|
||||
Optional("token"): str,
|
||||
Optional("verify"): bool,
|
||||
@@ -981,8 +1024,8 @@ schema = Schema({
|
||||
"etcd3": validate_etcd,
|
||||
"exhibitor": {
|
||||
"hosts": [str],
|
||||
"port": IntValidator(max=65535, raise_assert=True),
|
||||
Optional("pool_interval"): int
|
||||
"port": IntValidator(max=65535, expected_type=int, raise_assert=True),
|
||||
Optional("poll_interval"): IntValidator(min=1, expected_type=int, raise_assert=True),
|
||||
},
|
||||
"raft": {
|
||||
"self_addr": validate_connect_address,
|
||||
@@ -1013,14 +1056,14 @@ schema = Schema({
|
||||
Optional("tmp_role_label"): str,
|
||||
Optional("use_endpoints"): bool,
|
||||
Optional("pod_ip"): Or(is_ipv4_address, is_ipv6_address),
|
||||
Optional("ports"): [{"name": str, "port": int}],
|
||||
Optional("ports"): [{"name": str, "port": IntValidator(max=65535, expected_type=int, raise_assert=True)}],
|
||||
Optional("cacert"): str,
|
||||
Optional("retriable_http_codes"): Or(int, [int]),
|
||||
},
|
||||
}),
|
||||
Optional("citus"): {
|
||||
"database": str,
|
||||
"group": int
|
||||
"group": IntValidator(min=0, expected_type=int, raise_assert=True),
|
||||
},
|
||||
"postgresql": {
|
||||
"listen": validate_host_port_listen_multiple_hosts,
|
||||
@@ -1047,16 +1090,19 @@ schema = Schema({
|
||||
},
|
||||
Optional("pg_hba"): [str],
|
||||
Optional("pg_ident"): [str],
|
||||
Optional("pg_ctl_timeout"): int,
|
||||
Optional("pg_ctl_timeout"): IntValidator(min=0, raise_assert=True),
|
||||
Optional("use_pg_rewind"): bool
|
||||
},
|
||||
Optional("watchdog"): {
|
||||
Optional("mode"): validate_watchdog_mode,
|
||||
Optional("device"): str,
|
||||
Optional("safety_margin"): int
|
||||
Optional("safety_margin"): IntValidator(min=-1, expected_type=int, raise_assert=True),
|
||||
},
|
||||
Optional("tags"): {
|
||||
Optional("nofailover"): bool,
|
||||
AtMostOne("nofailover", "failover_priority"): Case({
|
||||
"nofailover": bool,
|
||||
"failover_priority": IntValidator(min=0, expected_type=int, raise_assert=True),
|
||||
}),
|
||||
Optional("clonefrom"): bool,
|
||||
Optional("noloadbalance"): bool,
|
||||
Optional("replicatefrom"): str,
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
:var __version__: the current Patroni version.
|
||||
"""
|
||||
__version__ = '3.1.0'
|
||||
__version__ = '3.2.0'
|
||||
|
||||
+6
-10
@@ -43,9 +43,13 @@ etcd:
|
||||
# - 127.0.0.1:2223
|
||||
# - 127.0.0.1:2224
|
||||
|
||||
# The bootstrap configuration. Works only when the cluster is not yet initialized.
|
||||
# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!
|
||||
bootstrap:
|
||||
# this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`
|
||||
# This section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`.
|
||||
# WARNING! If you want to change any of the parameters that were set up
|
||||
# via `bootstrap.dcs` section, please use `patronictl edit-config`!
|
||||
dcs:
|
||||
ttl: 30
|
||||
loop_wait: 10
|
||||
@@ -93,14 +97,6 @@ bootstrap:
|
||||
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
|
||||
# post_init: /usr/local/bin/setup_cluster.sh
|
||||
|
||||
# Some additional users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin%
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
|
||||
postgresql:
|
||||
listen: 127.0.0.1:5432
|
||||
connect_address: 127.0.0.1:5432
|
||||
|
||||
+6
-10
@@ -43,9 +43,13 @@ etcd:
|
||||
# - 127.0.0.1:2222
|
||||
# - 127.0.0.1:2224
|
||||
|
||||
# The bootstrap configuration. Works only when the cluster is not yet initialized.
|
||||
# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!
|
||||
bootstrap:
|
||||
# this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`
|
||||
# This section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`.
|
||||
# WARNING! If you want to change any of the parameters that were set up
|
||||
# via `bootstrap.dcs` section, please use `patronictl edit-config`!
|
||||
dcs:
|
||||
ttl: 30
|
||||
loop_wait: 10
|
||||
@@ -87,14 +91,6 @@ bootstrap:
|
||||
# Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
|
||||
# post_init: /usr/local/bin/setup_cluster.sh
|
||||
|
||||
# Some additional users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin%
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
|
||||
postgresql:
|
||||
listen: 127.0.0.1:5433
|
||||
connect_address: 127.0.0.1:5433
|
||||
|
||||
+6
-10
@@ -43,9 +43,13 @@ etcd:
|
||||
# - 127.0.0.1:2222
|
||||
# - 127.0.0.1:2223
|
||||
|
||||
# The bootstrap configuration. Works only when the cluster is not yet initialized.
|
||||
# If the cluster is already initialized, all changes in the `bootstrap` section are ignored!
|
||||
bootstrap:
|
||||
# this section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`
|
||||
# This section will be written into Etcd:/<namespace>/<scope>/config after initializing new cluster
|
||||
# and all other cluster members will use it as a `global configuration`.
|
||||
# WARNING! If you want to change any of the parameters that were set up
|
||||
# via `bootstrap.dcs` section, please use `patronictl edit-config`!
|
||||
dcs:
|
||||
ttl: 30
|
||||
loop_wait: 10
|
||||
@@ -84,14 +88,6 @@ bootstrap:
|
||||
- encoding: UTF8
|
||||
- data-checksums
|
||||
|
||||
# Some additional users which needs to be created after initializing new cluster
|
||||
users:
|
||||
admin:
|
||||
password: admin%
|
||||
options:
|
||||
- createrole
|
||||
- createdb
|
||||
|
||||
postgresql:
|
||||
listen: 127.0.0.1:5434
|
||||
connect_address: 127.0.0.1:5434
|
||||
|
||||
@@ -26,7 +26,6 @@ KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
|
||||
EXTRAS_REQUIRE = {'aws': ['boto3'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
|
||||
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
|
||||
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
|
||||
COVERAGE_XML = True
|
||||
|
||||
# Add here all kinds of additional classifiers as defined under
|
||||
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
|
||||
@@ -120,14 +119,21 @@ def read(fname):
|
||||
return fd.read()
|
||||
|
||||
|
||||
def setup_package(version):
|
||||
def get_versions():
|
||||
old_modules = sys.modules.copy()
|
||||
try:
|
||||
from patroni import MIN_PSYCOPG2, MIN_PSYCOPG3
|
||||
from patroni.version import __version__
|
||||
return __version__, MIN_PSYCOPG2, MIN_PSYCOPG3
|
||||
finally:
|
||||
sys.modules.clear()
|
||||
sys.modules.update(old_modules)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(format='%(message)s', level=os.getenv('LOGLEVEL', logging.WARNING))
|
||||
|
||||
# Assemble additional setup commands
|
||||
cmdclass = {'test': PyTest, 'flake8': Flake8}
|
||||
|
||||
install_requires = []
|
||||
|
||||
for r in read('requirements.txt').split('\n'):
|
||||
r = r.strip()
|
||||
if r == '':
|
||||
@@ -139,15 +145,22 @@ def setup_package(version):
|
||||
deps[i] = r
|
||||
EXTRAS_REQUIRE[e] = deps
|
||||
extra = True
|
||||
break
|
||||
if extra:
|
||||
break
|
||||
if not extra:
|
||||
install_requires.append(r)
|
||||
|
||||
# Just for convenience, if someone wants to install dependencies for all extras
|
||||
EXTRAS_REQUIRE['all'] = list({e for extras in EXTRAS_REQUIRE.values() for e in extras})
|
||||
|
||||
patroni_version, min_psycopg2, min_psycopg3 = get_versions()
|
||||
|
||||
# Make it possible to specify psycopg dependency as extra
|
||||
for name, version in {'psycopg[binary]': min_psycopg3, 'psycopg2': min_psycopg2, 'psycopg2-binary': None}.items():
|
||||
EXTRAS_REQUIRE[name] = [name + ('>=' + '.'.join(map(str, version)) if version else '')]
|
||||
EXTRAS_REQUIRE['psycopg3'] = EXTRAS_REQUIRE.pop('psycopg[binary]')
|
||||
|
||||
setup(
|
||||
name=NAME,
|
||||
version=version,
|
||||
version=patroni_version,
|
||||
url=URL,
|
||||
author=AUTHOR,
|
||||
author_email=AUTHOR_EMAIL,
|
||||
@@ -163,20 +176,10 @@ def setup_package(version):
|
||||
]},
|
||||
install_requires=install_requires,
|
||||
extras_require=EXTRAS_REQUIRE,
|
||||
cmdclass=cmdclass,
|
||||
cmdclass={'test': PyTest, 'flake8': Flake8},
|
||||
entry_points={'console_scripts': CONSOLE_SCRIPTS},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
old_modules = sys.modules.copy()
|
||||
try:
|
||||
from patroni import check_psycopg
|
||||
from patroni.version import __version__
|
||||
finally:
|
||||
sys.modules.clear()
|
||||
sys.modules.update(old_modules)
|
||||
|
||||
check_psycopg()
|
||||
|
||||
setup_package(__version__)
|
||||
main()
|
||||
|
||||
+14
-6
@@ -104,18 +104,20 @@ class MockCursor(object):
|
||||
elif sql.startswith('SELECT slot_name, slot_type, datname, plugin, catalog_xmin'):
|
||||
self.results = [('ls', 'logical', 'a', 'b', 100, 500, b'123456')]
|
||||
elif sql.startswith('SELECT slot_name'):
|
||||
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'b', 'a', 5, 100, 500)]
|
||||
self.results = [('blabla', 'physical', 12345),
|
||||
('foobar', 'physical', 12345),
|
||||
('ls', 'logical', 499, 'b', 'a', 5, 100, 500)]
|
||||
elif sql.startswith('WITH slots AS (SELECT slot_name, active'):
|
||||
self.results = [(False, True)] if self.rowcount == 1 else []
|
||||
elif sql.startswith('SELECT CASE WHEN pg_catalog.pg_is_in_recovery()'):
|
||||
self.results = [(1, 2, 1, 0, False, 1, 1, None, None, 'streaming', '',
|
||||
[{"slot_name": "ls", "confirmed_flush_lsn": 12345}],
|
||||
[{"slot_name": "ls", "confirmed_flush_lsn": 12345, "restart_lsn": 12344}],
|
||||
'on', 'n1', None)]
|
||||
elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'):
|
||||
self.results = [(False, 2)]
|
||||
elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'):
|
||||
self.results = [(datetime.datetime.now(tzutc),)]
|
||||
elif sql.startswith('SELECT name, current_setting(name) FROM pg_settings'):
|
||||
elif sql.startswith('SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings'):
|
||||
self.results = [('data_directory', 'data'),
|
||||
('hba_file', os.path.join('data', 'pg_hba.conf')),
|
||||
('ident_file', os.path.join('data', 'pg_ident.conf')),
|
||||
@@ -135,6 +137,11 @@ class MockCursor(object):
|
||||
('wal_block_size', '8192', None, 'integer', 'internal'),
|
||||
('shared_buffers', '16384', '8kB', 'integer', 'postmaster'),
|
||||
('wal_buffers', '-1', '8kB', 'integer', 'postmaster'),
|
||||
('max_connections', '100', None, 'integer', 'postmaster'),
|
||||
('max_prepared_transactions', '0', None, 'integer', 'postmaster'),
|
||||
('max_worker_processes', '8', None, 'integer', 'postmaster'),
|
||||
('max_locks_per_transaction', '64', None, 'integer', 'postmaster'),
|
||||
('max_wal_senders', '5', None, 'integer', 'postmaster'),
|
||||
('search_path', 'public', None, 'string', 'user'),
|
||||
('port', '5433', None, 'integer', 'postmaster'),
|
||||
('listen_addresses', '*', None, 'string', 'postmaster'),
|
||||
@@ -234,7 +241,7 @@ class PostgresInit(unittest.TestCase):
|
||||
'replication': {'username': '', 'password': 'rep-pass'},
|
||||
'rewind': {'username': 'rewind', 'password': 'test'}},
|
||||
'remove_data_directory_on_rewind_failure': True,
|
||||
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla',
|
||||
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla', 'use_unix_socket': True,
|
||||
'parameters': self._PARAMETERS,
|
||||
'recovery_conf': {'foo': 'bar'},
|
||||
'pg_hba': ['host all all 0.0.0.0/0 md5'],
|
||||
@@ -246,14 +253,15 @@ class PostgresInit(unittest.TestCase):
|
||||
|
||||
class BaseTestPostgresql(PostgresInit):
|
||||
|
||||
@patch('time.sleep', Mock())
|
||||
def setUp(self):
|
||||
super(BaseTestPostgresql, self).setUp()
|
||||
|
||||
if not os.path.exists(self.p.data_dir):
|
||||
os.makedirs(self.p.data_dir)
|
||||
|
||||
self.leadermem = Member(0, 'leader', 28, {
|
||||
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5435/postgres'})
|
||||
self.leadermem = Member(0, 'leader', 28, {'xlog_location': 100, 'state': 'running',
|
||||
'conn_url': 'postgres://replicator:[email protected]:5435/postgres'})
|
||||
self.leader = Leader(-1, 28, self.leadermem)
|
||||
self.other = Member(0, 'test-1', 28, {'conn_url': 'postgres://replicator:[email protected]:5433/postgres',
|
||||
'state': 'running', 'tags': {'replicatefrom': 'leader'}})
|
||||
|
||||
+34
-62
@@ -13,12 +13,11 @@ from patroni.config import GlobalConfig
|
||||
from patroni.dcs import ClusterConfig, Member
|
||||
from patroni.exceptions import PostgresConnectionException
|
||||
from patroni.ha import _MemberStatus
|
||||
from patroni.manual_failover import ManualFailoverPrecheckStatus
|
||||
from patroni.psycopg import OperationalError
|
||||
from patroni.utils import ParseScheduleErrors, RetryFailedError, tzutc
|
||||
from patroni.utils import RetryFailedError, tzutc
|
||||
|
||||
from . import MockConnect, psycopg_connect
|
||||
from .test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader
|
||||
from .test_ha import get_cluster_initialized_without_leader
|
||||
|
||||
|
||||
future_restart_time = datetime.datetime.now(tzutc) + datetime.timedelta(days=5)
|
||||
@@ -141,9 +140,6 @@ class MockHa(object):
|
||||
def is_paused():
|
||||
return True
|
||||
|
||||
def has_members_eligible_to_promote(*args, **kwargs):
|
||||
return True
|
||||
|
||||
|
||||
class MockLogger(object):
|
||||
|
||||
@@ -523,7 +519,7 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
# Invalid content
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, post + '7\n\n{"1":2}')
|
||||
response_mock.assert_called_with(*ManualFailoverPrecheckStatus.SWITCHOVER_NO_LEADER.value[::-1])
|
||||
response_mock.assert_called_with(400, 'Switchover could be performed only from a specific leader')
|
||||
|
||||
# Empty content
|
||||
request = post + '0\n\n'
|
||||
@@ -531,19 +527,25 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
|
||||
# [Switchover without a candidate]
|
||||
|
||||
cluster.leader.name = 'postgresql1'
|
||||
request = post + '25\n\n{"leader": "postgresql1"}'
|
||||
# Cluster with only a leader
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
cluster.leader.name = 'postgresql1'
|
||||
request = post + '25\n\n{"leader": "postgresql1"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(
|
||||
412, 'switchover is not possible: cluster does not have members except leader')
|
||||
|
||||
# No candidate in pause mode
|
||||
# Switchover in pause mode
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock, \
|
||||
patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(*ManualFailoverPrecheckStatus.SWITCHOVER_PAUSE_NO_CANDIDATE.value[::-1])
|
||||
response_mock.assert_called_with(
|
||||
400, 'Switchover is possible only to a specific candidate in a paused state')
|
||||
|
||||
# No healthy nodes to promote in both sync and async mode
|
||||
for is_synchronous_mode, response in (
|
||||
(True, ManualFailoverPrecheckStatus.NO_SYNC_CANDIDATE.value[0].format(action='switchover')),
|
||||
(False, ManualFailoverPrecheckStatus.ONLY_LEADER.value[0].format(action='switchover'))):
|
||||
(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(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)), \
|
||||
patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
@@ -555,25 +557,20 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
request = post + '53\n\n{"leader": "postgresql2", "candidate": "postgresql2"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(*ManualFailoverPrecheckStatus.SWITCHOVER_TO_LEADER.value[::-1])
|
||||
response_mock.assert_called_with(400, 'Switchover target and source are the same')
|
||||
|
||||
# Current leader is different from the one specified
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
cluster.leader.name = 'postgresql2'
|
||||
request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(
|
||||
ManualFailoverPrecheckStatus.LEADER_NOT_MEMBER.value[1],
|
||||
ManualFailoverPrecheckStatus.LEADER_NOT_MEMBER.value[0].format(leader='postgresql1',
|
||||
cluster_name='dummy'))
|
||||
response_mock.assert_called_with(412, 'leader name does not match')
|
||||
|
||||
# Candidate to promote is not a sync standby/a member of the cluster
|
||||
# Candidate to promote is not a member of the cluster
|
||||
cluster.leader.name = 'postgresql1'
|
||||
cluster.sync.matches.return_value = False
|
||||
for is_synchronous_mode, response in (
|
||||
(True, ManualFailoverPrecheckStatus.CANDIDATE_NOT_SYNC_STANDBY.value[0]),
|
||||
(False, ManualFailoverPrecheckStatus.CANDIDATE_NOT_MEMEBER.value[0].format(candidate="postgresql2",
|
||||
cluster_name='dummy'))):
|
||||
(True, 'candidate name does not match with sync_standby'), (False, 'candidate does not exists')):
|
||||
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=is_synchronous_mode)), \
|
||||
patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
@@ -582,21 +579,9 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'}),
|
||||
Member(0, 'postgresql2', 30, {'api_url': 'http'})]
|
||||
|
||||
# Cluster has no leader
|
||||
cluster.leader.name = None
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
request = post + '53\n\n{"leader": "postgresql1"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(
|
||||
ManualFailoverPrecheckStatus.CLUSTER_NO_LEADER.value[1],
|
||||
ManualFailoverPrecheckStatus.CLUSTER_NO_LEADER.value[0].format(leader='leader', cluster_name='dummy'))
|
||||
|
||||
cluster.leader.name = 'postgresql1'
|
||||
|
||||
# Failover key is empty in DCS
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
cluster.failover = None
|
||||
request = post + '53\n\n{"leader": "postgresql1", "candidate": "postgresql2"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(503, 'Switchover failed')
|
||||
|
||||
@@ -631,12 +616,10 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
dcs.manual_failover.return_value = True
|
||||
|
||||
# Candidate is not healthy to be promoted
|
||||
with patch.object(MockHa, 'has_members_eligible_to_promote', Mock(return_value=False)), \
|
||||
with patch.object(MockHa, 'fetch_nodes_statuses', Mock(return_value=[])), \
|
||||
patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(
|
||||
ManualFailoverPrecheckStatus.NO_GOOD_CANDIDATES.value[1],
|
||||
ManualFailoverPrecheckStatus.NO_GOOD_CANDIDATES.value[0].format(action='switchover'))
|
||||
response_mock.assert_called_with(412, 'switchover is not possible: no good candidates have been found')
|
||||
|
||||
# [Scheduled switchover]
|
||||
|
||||
@@ -647,58 +630,47 @@ class TestRestApiHandler(unittest.TestCase):
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(202, 'Switchover scheduled')
|
||||
|
||||
# Scheduled in pause mode
|
||||
# Schedule in paused mode
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock, \
|
||||
patch.object(GlobalConfig, 'is_paused', PropertyMock(return_value=True)):
|
||||
dcs.manual_failover.return_value = False
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(*ManualFailoverPrecheckStatus.SCHEDULED_SWITCHOVER_PAUSE.value[::-1])
|
||||
response_mock.assert_called_with(400, "Can't schedule switchover in the paused state")
|
||||
|
||||
# No timezone specified
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
request = post + '97\n\n{"leader": "postgresql1", "member": "postgresql2",' + \
|
||||
' "scheduled_at": "6016-02-15T18:13:30.568224"}'
|
||||
MockRestApiServer(RestApiHandler, request)
|
||||
response_mock.assert_called_with(
|
||||
ParseScheduleErrors.NO_TIMEZONE.value[1],
|
||||
ParseScheduleErrors.NO_TIMEZONE.value[0].format(action='switchover'))
|
||||
response_mock.assert_called_with(400, 'Timezone information is mandatory for the scheduled switchover')
|
||||
|
||||
request = post + '103\n\n{"leader": "postgresql1", "member": "postgresql2", "scheduled_at": "'
|
||||
|
||||
# Scheduled in the past
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, request + '1016-02-15T18:13:30.568224+01:00"}')
|
||||
response_mock.assert_called_with(
|
||||
ParseScheduleErrors.SCHEDULED_IN_PAST.value[1],
|
||||
ParseScheduleErrors.SCHEDULED_IN_PAST.value[0].format(action='switchover'))
|
||||
response_mock.assert_called_with(422, 'Cannot schedule switchover in the past')
|
||||
|
||||
# Invalid date
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, request + '2010-02-29T18:13:30.568224+01:00"}')
|
||||
response_mock.assert_called_with(*ParseScheduleErrors.PARSING_ERROR.value[::-1])
|
||||
response_mock.assert_called_with(
|
||||
422, 'Unable to parse scheduled timestamp. It should be in an unambiguous format, e.g. ISO 8601')
|
||||
|
||||
@patch.object(MockPatroni, 'dcs')
|
||||
def test_do_POST_failover(self, mock_dcs):
|
||||
def test_do_POST_failover(self):
|
||||
post = 'POST /failover HTTP/1.0' + self._authorization + '\nContent-Length: '
|
||||
cluster = mock_dcs.get_cluster.return_value
|
||||
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, post + '19\n\n{"leader":"leader"}')
|
||||
response_mock.assert_called_once_with(*ManualFailoverPrecheckStatus.FAILOVER_NO_CANDIDATE.value[::-1])
|
||||
MockRestApiServer(RestApiHandler, post + '14\n\n{"leader":"1"}')
|
||||
response_mock.assert_called_once_with(400, 'Failover could be performed only to a specific candidate')
|
||||
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, post + '37\n\n{"candidate":"2","scheduled_at": "1"}')
|
||||
response_mock.assert_called_once_with(*ManualFailoverPrecheckStatus.SCHEDULED_FAILOVER.value[::-1])
|
||||
response_mock.assert_called_once_with(400, "Failover can't be scheduled")
|
||||
|
||||
# Candidate is not healthy to be promoted
|
||||
cluster.members = [Member(0, 'postgresql0', 30, {'api_url': 'http'}),
|
||||
Member(0, 'postgresql2', 30, {'api_url': 'http'})]
|
||||
with patch.object(MockHa, 'has_members_eligible_to_promote', Mock(return_value=False)), \
|
||||
patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, post + '27\n\n{"candidate":"postgresql2"}')
|
||||
response_mock.assert_called_with(
|
||||
ManualFailoverPrecheckStatus.NO_GOOD_CANDIDATES.value[1],
|
||||
ManualFailoverPrecheckStatus.NO_GOOD_CANDIDATES.value[0].format(action='failover'))
|
||||
with patch.object(RestApiHandler, 'write_response') as response_mock:
|
||||
MockRestApiServer(RestApiHandler, post + '30\n\n{"leader":"1","candidate":"2"}')
|
||||
response_mock.assert_called_once_with(412, 'leader name does not match')
|
||||
|
||||
@patch.object(MockHa, 'is_leader', Mock(return_value=True))
|
||||
def test_do_POST_citus(self):
|
||||
|
||||
@@ -238,7 +238,8 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
self.p.reload_config({'authentication': {'superuser': {'username': 'p', 'password': 'p'},
|
||||
'replication': {'username': 'r', 'password': 'r'},
|
||||
'rewind': {'username': 'rw', 'password': 'rw'}},
|
||||
'listen': '*', 'retry_timeout': 10, 'parameters': {'wal_level': '', 'hba_file': 'foo'}})
|
||||
'listen': '*', 'retry_timeout': 10,
|
||||
'parameters': {'wal_level': '', 'hba_file': 'foo', 'max_prepared_transactions': 10}})
|
||||
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=110000)), \
|
||||
patch.object(Postgresql, 'restart', Mock()) as mock_restart:
|
||||
self.b.post_bootstrap({}, task)
|
||||
@@ -255,7 +256,7 @@ class TestBootstrap(BaseTestPostgresql):
|
||||
mock_cancellable_subprocess_call.assert_called()
|
||||
args, kwargs = mock_cancellable_subprocess_call.call_args
|
||||
self.assertTrue('PGPASSFILE' in kwargs['env'])
|
||||
self.assertEqual(args[0], ['/bin/false', 'dbname=postgres host=127.0.0.2 port=5432'])
|
||||
self.assertEqual(args[0], ['/bin/false', 'dbname=postgres host=/tmp port=5432'])
|
||||
|
||||
mock_cancellable_subprocess_call.reset_mock()
|
||||
self.p.connection_pool._conn_kwargs.pop('host')
|
||||
|
||||
@@ -35,5 +35,6 @@ class TestCallbackExecutor(unittest.TestCase):
|
||||
ce._invoke_excepthook = Mock()
|
||||
self.assertIsNone(ce.call(callback))
|
||||
|
||||
mock_popen.side_effect = [Mock()]
|
||||
self.assertIsNone(ce.call(['test.sh', 'on_reload', 'replica', 'foo']))
|
||||
ce.join()
|
||||
|
||||
+1
-1
@@ -13,7 +13,6 @@ class TestCitus(BaseTestPostgresql):
|
||||
def setUp(self):
|
||||
super(TestCitus, self).setUp()
|
||||
self.c = self.p.citus_handler
|
||||
self.p.connection_pool.conn_kwargs = {'host': 'localhost', 'dbname': 'postgres'}
|
||||
self.cluster = get_cluster_initialized_with_leader()
|
||||
self.cluster.workers[1] = self.cluster
|
||||
|
||||
@@ -139,6 +138,7 @@ class TestCitus(BaseTestPostgresql):
|
||||
self.assertEqual(parameters['max_prepared_transactions'], 202)
|
||||
self.assertEqual(parameters['shared_preload_libraries'], 'citus,foo,bar')
|
||||
self.assertEqual(parameters['wal_level'], 'logical')
|
||||
self.assertEqual(parameters['citus.local_hostname'], '/tmp')
|
||||
|
||||
def test_bootstrap(self):
|
||||
self.c._config = None
|
||||
|
||||
+102
-2
@@ -3,8 +3,9 @@ import sys
|
||||
import unittest
|
||||
import io
|
||||
|
||||
from copy import deepcopy
|
||||
from mock import MagicMock, Mock, patch
|
||||
from patroni.config import Config, ConfigParseError
|
||||
from patroni.config import Config, ConfigParseError, GlobalConfig
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
@@ -22,7 +23,7 @@ class TestConfig(unittest.TestCase):
|
||||
self.assertFalse(self.config.set_dynamic_configuration({'foo': 'bar'}))
|
||||
self.assertTrue(self.config.set_dynamic_configuration({'standby_cluster': {}, 'postgresql': {
|
||||
'parameters': {'cluster_name': 1, 'hot_standby': 1, 'wal_keep_size': 1,
|
||||
'track_commit_timestamp': 1, 'wal_level': 1}}}))
|
||||
'track_commit_timestamp': 1, 'wal_level': 1, 'max_connections': '100'}}}))
|
||||
|
||||
def test_reload_local_configuration(self):
|
||||
os.environ.update({
|
||||
@@ -149,3 +150,102 @@ class TestConfig(unittest.TestCase):
|
||||
@patch('os.path.isdir', Mock(return_value=False))
|
||||
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, mock_get):
|
||||
"""Ensures that only one of `nofailover` or `failover_priority` can be provided"""
|
||||
mock_logger.warning.reset_mock()
|
||||
config = Config("postgres0.yml")
|
||||
# Providing one of `nofailover` or `failover_priority` is fine
|
||||
just_nofailover = {"nofailover": True}
|
||||
mock_get.side_effect = [just_nofailover] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
just_failover_priority = {"failover_priority": 1}
|
||||
mock_get.side_effect = [just_failover_priority] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
# Providing both `nofailover` and `failover_priority` is fine if consistent
|
||||
consistent_false = {"nofailover": False, "failover_priority": 1}
|
||||
mock_get.side_effect = [consistent_false] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
consistent_true = {"nofailover": True, "failover_priority": 0}
|
||||
mock_get.side_effect = [consistent_true] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_not_called()
|
||||
# Providing both inconsistently should log a warning
|
||||
inconsistent_false = {"nofailover": False, "failover_priority": 0}
|
||||
mock_get.side_effect = [inconsistent_false] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
|
||||
+ ' Defaulting to nofailover: %s',
|
||||
False,
|
||||
0,
|
||||
False
|
||||
)
|
||||
mock_logger.warning.reset_mock()
|
||||
inconsistent_true = {"nofailover": True, "failover_priority": 1}
|
||||
mock_get.side_effect = [inconsistent_true] * 2
|
||||
self.assertIsNone(config._validate_failover_tags())
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
'Conflicting configuration between nofailover: %s and failover_priority: %s.'
|
||||
+ ' Defaulting to nofailover: %s',
|
||||
True,
|
||||
1,
|
||||
True
|
||||
)
|
||||
|
||||
def test__process_postgresql_parameters(self):
|
||||
expected_params = {
|
||||
'f.oo': 'bar', # not in ConfigHandler.CMDLINE_OPTIONS
|
||||
'max_connections': 100, # IntValidator
|
||||
'wal_keep_size': '128MB', # IntValidator
|
||||
'wal_level': 'hot_standby', # EnumValidator
|
||||
}
|
||||
input_params = deepcopy(expected_params)
|
||||
|
||||
input_params['max_connections'] = '100'
|
||||
self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params)
|
||||
|
||||
expected_params['f.oo'] = input_params['f.oo'] = '100'
|
||||
self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params)
|
||||
|
||||
input_params['wal_level'] = 'cold_standby'
|
||||
expected_params.pop('wal_level')
|
||||
self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params)
|
||||
|
||||
input_params['max_connections'] = 10
|
||||
expected_params.pop('max_connections')
|
||||
self.assertEqual(self.config._process_postgresql_parameters(input_params), expected_params)
|
||||
|
||||
def test__validate_and_adjust_timeouts(self):
|
||||
with patch('patroni.config.logger.warning') as mock_logger:
|
||||
self.config._validate_and_adjust_timeouts({'ttl': 15})
|
||||
self.assertEqual(mock_logger.call_args_list[0][0],
|
||||
("%s=%d can't be smaller than %d, adjusting...", 'ttl', 15, 20))
|
||||
with patch('patroni.config.logger.warning') as mock_logger:
|
||||
self.config._validate_and_adjust_timeouts({'loop_wait': 0})
|
||||
self.assertEqual(mock_logger.call_args_list[0][0],
|
||||
("%s=%d can't be smaller than %d, adjusting...", 'loop_wait', 0, 1))
|
||||
with patch('patroni.config.logger.warning') as mock_logger:
|
||||
self.config._validate_and_adjust_timeouts({'retry_timeout': 1})
|
||||
self.assertEqual(mock_logger.call_args_list[0][0],
|
||||
("%s=%d can't be smaller than %d, adjusting...", 'retry_timeout', 1, 3))
|
||||
with patch('patroni.config.logger.warning') as mock_logger:
|
||||
self.config._validate_and_adjust_timeouts({'ttl': 20, 'loop_wait': 11, 'retry_timeout': 5})
|
||||
self.assertEqual(mock_logger.call_args_list[0][0],
|
||||
('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d '
|
||||
'and retry_timeout=%d. Adjusting loop_wait from %d to %d', 20, 5, 11, 10))
|
||||
with patch('patroni.config.logger.warning') as mock_logger:
|
||||
self.config._validate_and_adjust_timeouts({'ttl': 20, 'loop_wait': 10, 'retry_timeout': 10})
|
||||
self.assertEqual(mock_logger.call_args_list[0][0],
|
||||
('Violated the rule "loop_wait + 2*retry_timeout <= ttl", where ttl=%d. Adjusting'
|
||||
' loop_wait from %d to %d and retry_timeout from %d to %d', 20, 10, 1, 10, 9))
|
||||
|
||||
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}
|
||||
self.assertFalse(GlobalConfig(config).is_synchronous_mode)
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
import os
|
||||
import psutil
|
||||
import socket
|
||||
import unittest
|
||||
import yaml
|
||||
|
||||
from . import MockConnect, MockCursor, MockConnectionInfo
|
||||
from copy import deepcopy
|
||||
from mock import MagicMock, Mock, PropertyMock, mock_open, patch
|
||||
from mock import MagicMock, Mock, PropertyMock, mock_open as _mock_open, patch
|
||||
|
||||
from patroni.__main__ import main as _main
|
||||
from patroni.config import Config
|
||||
from patroni.config_generator import AbstractConfigGenerator, get_address
|
||||
|
||||
from patroni.config_generator import AbstractConfigGenerator, get_address, NO_VALUE_MSG
|
||||
from patroni.log import PatroniLogger
|
||||
from patroni.utils import patch_config
|
||||
|
||||
from . import psycopg_connect
|
||||
|
||||
HOSTNAME = 'test_hostname'
|
||||
IP = '1.9.8.4'
|
||||
|
||||
|
||||
def mock_open(*args, **kwargs):
|
||||
ret = _mock_open(*args, **kwargs)
|
||||
ret.return_value.__iter__ = lambda o: iter(o.readline, '')
|
||||
if not kwargs.get('read_data'):
|
||||
ret.return_value.readline = Mock(return_value=None)
|
||||
return ret
|
||||
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch('socket.getaddrinfo', Mock(return_value=[(0, 0, 0, 0, ('1.9.8.4', 1984))]))
|
||||
@patch('builtins.open', MagicMock())
|
||||
@patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 16.2"))
|
||||
@patch('psutil.Process.exe', Mock(return_value='/bin/dir/from/running/postgres'))
|
||||
@patch('psutil.Process.__init__', Mock(return_value=None))
|
||||
@patch.object(AbstractConfigGenerator, '_HOSTNAME', HOSTNAME)
|
||||
@patch.object(AbstractConfigGenerator, '_IP', IP)
|
||||
class TestGenerateConfig(unittest.TestCase):
|
||||
|
||||
no_value_msg = '#FIXME'
|
||||
_HOSTNAME = socket.gethostname()
|
||||
_IP = sorted(socket.getaddrinfo(_HOSTNAME, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0), key=lambda x: x[0])[0][4][0]
|
||||
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
|
||||
os.environ['PATRONI_SCOPE'] = 'scope_from_env'
|
||||
os.environ['PATRONI_POSTGRESQL_BIN_DIR'] = '/bin/from/env'
|
||||
os.environ['PATRONI_SUPERUSER_USERNAME'] = 'su_user_from_env'
|
||||
@@ -54,14 +60,24 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
|
||||
self.config = {
|
||||
'scope': self.environ['PATRONI_SCOPE'],
|
||||
'name': self._HOSTNAME,
|
||||
'name': HOSTNAME,
|
||||
'log': {
|
||||
'level': PatroniLogger.DEFAULT_LEVEL,
|
||||
'traceback_level': PatroniLogger.DEFAULT_TRACEBACK_LEVEL,
|
||||
'format': PatroniLogger.DEFAULT_FORMAT,
|
||||
'max_queue_size': PatroniLogger.DEFAULT_MAX_QUEUE_SIZE
|
||||
},
|
||||
'restapi': {
|
||||
'connect_address': self.environ['PATRONI_RESTAPI_CONNECT_ADDRESS'],
|
||||
'listen': self.environ['PATRONI_RESTAPI_LISTEN']
|
||||
},
|
||||
'bootstrap': {
|
||||
'dcs': dynamic_config
|
||||
},
|
||||
'postgresql': {
|
||||
'connect_address': self.no_value_msg + ':5432',
|
||||
'data_dir': self.no_value_msg,
|
||||
'listen': self.no_value_msg + ':5432',
|
||||
'connect_address': IP + ':5432',
|
||||
'data_dir': NO_VALUE_MSG,
|
||||
'listen': IP + ':5432',
|
||||
'pg_hba': ['host all all all md5',
|
||||
f'host replication {self.environ["PATRONI_REPLICATION_USERNAME"]} all md5'],
|
||||
'authentication': {'superuser': {'username': self.environ['PATRONI_SUPERUSER_USERNAME'],
|
||||
@@ -72,10 +88,6 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
'bin_dir': self.environ['PATRONI_POSTGRESQL_BIN_DIR'],
|
||||
'bin_name': {'postgres': self.environ['PATRONI_POSTGRESQL_BIN_POSTGRES']},
|
||||
'parameters': {'password_encryption': 'md5'}
|
||||
},
|
||||
'restapi': {
|
||||
'connect_address': self.environ['PATRONI_RESTAPI_CONNECT_ADDRESS'],
|
||||
'listen': self.environ['PATRONI_RESTAPI_LISTEN']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +111,7 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
}
|
||||
},
|
||||
'postgresql': {
|
||||
'connect_address': f'{self._IP}:bar',
|
||||
'connect_address': f'{IP}:bar',
|
||||
'listen': '6.6.6.6:1984',
|
||||
'data_dir': 'data',
|
||||
'bin_dir': '/bin/dir/from/running',
|
||||
@@ -118,11 +130,17 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
'sslmode': 'prefer'
|
||||
},
|
||||
'replication': {
|
||||
'username': self.no_value_msg,
|
||||
'password': self.no_value_msg
|
||||
'username': NO_VALUE_MSG,
|
||||
'password': NO_VALUE_MSG
|
||||
},
|
||||
'rewind': None
|
||||
},
|
||||
},
|
||||
'tags': {
|
||||
'failover_priority': 1,
|
||||
'noloadbalance': False,
|
||||
'clonefrom': True,
|
||||
'nosync': False,
|
||||
}
|
||||
}
|
||||
patch_config(self.config, conf)
|
||||
@@ -143,22 +161,21 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
]
|
||||
|
||||
@patch('os.makedirs')
|
||||
@patch('yaml.safe_dump')
|
||||
def test_generate_sample_config_pre_13_dir_creation(self, mock_config_dump, mock_makedir):
|
||||
def test_generate_sample_config_pre_13_dir_creation(self, mock_makedir):
|
||||
with patch('sys.argv', ['patroni.py', '--generate-sample-config', '/foo/bar.yml']), \
|
||||
patch('subprocess.check_output', Mock(return_value=b"postgres (PostgreSQL) 9.4.3")) as pg_bin_mock, \
|
||||
patch('builtins.open', _mock_open()) as mocked_file, \
|
||||
self.assertRaises(SystemExit) as e:
|
||||
_main()
|
||||
self.assertEqual(self.config, yaml.safe_load(mocked_file().write.call_args_list[0][0][0]))
|
||||
self.assertEqual(e.exception.code, 0)
|
||||
self.assertEqual(self.config, mock_config_dump.call_args[0][0])
|
||||
mock_makedir.assert_called_once()
|
||||
pg_bin_mock.assert_called_once_with([os.path.join(self.environ['PATRONI_POSTGRESQL_BIN_DIR'],
|
||||
self.environ['PATRONI_POSTGRESQL_BIN_POSTGRES']),
|
||||
'--version'])
|
||||
|
||||
@patch('os.makedirs', Mock())
|
||||
@patch('yaml.safe_dump')
|
||||
def test_generate_sample_config_16(self, mock_config_dump):
|
||||
def test_generate_sample_config_16(self):
|
||||
conf = {
|
||||
'bootstrap': {
|
||||
'dcs': {
|
||||
@@ -179,21 +196,22 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
'authentication': {
|
||||
'rewind': {
|
||||
'username': self.environ['PATRONI_REWIND_USERNAME'],
|
||||
'password': self.no_value_msg}
|
||||
'password': NO_VALUE_MSG}
|
||||
},
|
||||
}
|
||||
}
|
||||
patch_config(self.config, conf)
|
||||
|
||||
with patch('sys.argv', ['patroni.py', '--generate-sample-config', '/foo/bar.yml']), \
|
||||
patch('builtins.open', _mock_open()) as mocked_file, \
|
||||
self.assertRaises(SystemExit) as e:
|
||||
_main()
|
||||
self.assertEqual(self.config, yaml.safe_load(mocked_file().write.call_args_list[0][0][0]))
|
||||
self.assertEqual(e.exception.code, 0)
|
||||
self.assertEqual(self.config, mock_config_dump.call_args[0][0])
|
||||
|
||||
@patch('os.makedirs', Mock())
|
||||
@patch('yaml.safe_dump')
|
||||
def test_generate_config_running_instance_16(self, mock_config_dump):
|
||||
@patch('sys.stdout')
|
||||
def test_generate_config_running_instance_16(self, mock_sys_stdout):
|
||||
self._set_running_instance_config_vals()
|
||||
|
||||
with patch('builtins.open', Mock(side_effect=self._get_running_instance_open_res())), \
|
||||
@@ -202,11 +220,11 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
self.assertRaises(SystemExit) as e:
|
||||
_main()
|
||||
self.assertEqual(e.exception.code, 0)
|
||||
self.assertEqual(self.config, mock_config_dump.call_args[0][0])
|
||||
self.assertEqual(self.config, yaml.safe_load(mock_sys_stdout.write.call_args_list[0][0][0]))
|
||||
|
||||
@patch('os.makedirs', Mock())
|
||||
@patch('yaml.safe_dump')
|
||||
def test_generate_config_running_instance_16_connect_from_env(self, mock_config_dump):
|
||||
@patch('sys.stdout')
|
||||
def test_generate_config_running_instance_16_connect_from_env(self, mock_sys_stdout):
|
||||
self._set_running_instance_config_vals()
|
||||
# su auth params and connect host from env
|
||||
os.environ['PGCHANNELBINDING'] = \
|
||||
@@ -230,7 +248,7 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
}
|
||||
},
|
||||
'postgresql': {
|
||||
'connect_address': f'{self._IP}:1984',
|
||||
'connect_address': f'{IP}:1984',
|
||||
'authentication': {
|
||||
'superuser': {
|
||||
'username': self.environ['PGUSER'],
|
||||
@@ -249,7 +267,7 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
self.assertRaises(SystemExit) as e:
|
||||
_main()
|
||||
self.assertEqual(e.exception.code, 0)
|
||||
self.assertEqual(self.config, mock_config_dump.call_args[0][0])
|
||||
self.assertEqual(self.config, yaml.safe_load(mock_sys_stdout.write.call_args_list[0][0][0]))
|
||||
|
||||
def test_generate_config_running_instance_errors(self):
|
||||
# 1. Wrong DSN format
|
||||
@@ -330,5 +348,5 @@ class TestGenerateConfig(unittest.TestCase):
|
||||
def test_get_address(self):
|
||||
with patch('socket.getaddrinfo', Mock(side_effect=Exception)), \
|
||||
patch('logging.warning') as mock_warning:
|
||||
self.assertEqual(get_address(), (self.no_value_msg, self.no_value_msg))
|
||||
self.assertEqual(get_address(), (NO_VALUE_MSG, NO_VALUE_MSG))
|
||||
self.assertIn('Failed to obtain address: %r', mock_warning.call_args_list[0][0])
|
||||
|
||||
+137
-203
@@ -6,14 +6,12 @@ import unittest
|
||||
from click.testing import CliRunner
|
||||
from datetime import datetime, timedelta
|
||||
from mock import patch, Mock, PropertyMock
|
||||
from patroni.config import GlobalConfig
|
||||
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.etcd import AbstractEtcdClientWithFailover, Cluster, Failover
|
||||
from patroni.manual_failover import ManualFailoverPrecheckStatus
|
||||
from patroni.psycopg import OperationalError
|
||||
from patroni.utils import ParseScheduleErrors, tzutc
|
||||
from patroni.utils import tzutc
|
||||
from prettytable import PrettyTable, ALL
|
||||
from urllib3 import PoolManager
|
||||
|
||||
@@ -37,10 +35,6 @@ DEFAULT_CONFIG = {
|
||||
class TestCtl(unittest.TestCase):
|
||||
TEST_ROLES = ('master', 'primary', 'leader')
|
||||
|
||||
SCHEDULED_TS = '2055-01-01T12:00:00+01:00'
|
||||
SCHEDULED_TS_NO_TZ = '2055-01-01T12:00:00'
|
||||
SCHEDULED_TS_INVALID = '2055-02-30T12:00:00'
|
||||
|
||||
@patch('socket.getaddrinfo', socket_getaddrinfo)
|
||||
@patch.object(AbstractEtcdClientWithFailover, '_get_machines_list', Mock(return_value=['http://remotehost:2379']))
|
||||
def setUp(self):
|
||||
@@ -81,6 +75,21 @@ class TestCtl(unittest.TestCase):
|
||||
|
||||
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')
|
||||
|
||||
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'))
|
||||
|
||||
# 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
|
||||
assert parse_dcs('localhost') == {'etcd': {'host': 'localhost:2379'}}
|
||||
@@ -122,6 +131,70 @@ class TestCtl(unittest.TestCase):
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--force'])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
# Scheduled (confirm)
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'],
|
||||
input='leader\nother\n2300-01-01T12:23:00\ny')
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
# Scheduled (abort)
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||
'--scheduled', '2015-01-01T12:00:00+01:00'], input='leader\nother\n\nN')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
# Scheduled with --force option
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||
'--force', '--scheduled', '2015-01-01T12:00:00+01:00'])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
# Scheduled in pause mode
|
||||
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)
|
||||
self.assertIn("Can't schedule switchover in the paused state", result.output)
|
||||
|
||||
# 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)
|
||||
|
||||
# Candidate is not a member of the cluster
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nReality\n\ny')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Member Reality does not exist in cluster dummy or is tagged as nofailover', result.output)
|
||||
|
||||
# Invalid timestamp
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--force', '--scheduled', 'invalid'])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Unable to parse scheduled timestamp', result.output)
|
||||
|
||||
# Invalid timestamp
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||
'--force', '--scheduled', '2115-02-30T12:00:00+01:00'])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Unable to parse scheduled timestamp', result.output)
|
||||
|
||||
# Specifying wrong leader
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='dummy')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Member dummy is not the leader of cluster dummy', result.output)
|
||||
|
||||
# Errors while sending Patroni REST API request
|
||||
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.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)
|
||||
|
||||
mock_api_request.return_value.status = 501
|
||||
mock_api_request.return_value.data = b'Server does not support this operation'
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
||||
self.assertIn('Switchover failed', result.output)
|
||||
|
||||
# No members available
|
||||
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')
|
||||
@@ -139,132 +212,6 @@ class TestCtl(unittest.TestCase):
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('For Citus clusters the --group must me specified', result.output)
|
||||
|
||||
# [Scheduled]
|
||||
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
|
||||
# Scheduled (confirm)
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'],
|
||||
input=f'leader\nother\n{self.SCHEDULED_TS}\ny')
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assertIn(f'Are you sure you want to schedule a switchover in the cluster dummy '
|
||||
f'at {self.SCHEDULED_TS}, demoting current leader', result.output)
|
||||
|
||||
# Scheduled (abort)
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||
'--scheduled', self.SCHEDULED_TS], input='leader\nother\n\nN')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
# Scheduled with --force option
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||
'--force', '--scheduled', self.SCHEDULED_TS])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
# Scheduled in pause mode
|
||||
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||
'--force', '--scheduled', self.SCHEDULED_TS])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(ManualFailoverPrecheckStatus.SCHEDULED_SWITCHOVER_PAUSE.value[0], result.output)
|
||||
|
||||
# Invalid timestamp with force
|
||||
result = self.runner.invoke(ctl,['switchover', 'dummy', '--group', '0', '--force', '--scheduled',
|
||||
self.SCHEDULED_TS_INVALID])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Unable to parse scheduled timestamp', result.output)
|
||||
|
||||
# Invalid timestamp
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||
'--force', '--scheduled', self.SCHEDULED_TS_INVALID])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Unable to parse scheduled timestamp', result.output)
|
||||
|
||||
# Invalid timestamp - no timezone
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0',
|
||||
'--force', '--scheduled', self.SCHEDULED_TS_NO_TZ])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(ParseScheduleErrors.NO_TIMEZONE.value[0].format(action='switchover'), result.output)
|
||||
|
||||
# [Other erroneous combinations]
|
||||
|
||||
# No candidate in pause mode
|
||||
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\n\n\ny')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(ManualFailoverPrecheckStatus.SWITCHOVER_PAUSE_NO_CANDIDATE.value[0], result.output)
|
||||
|
||||
# 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(ManualFailoverPrecheckStatus.SWITCHOVER_TO_LEADER.value[0], result.output)
|
||||
|
||||
# Candidate is not a member of the cluster
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nReality\n\ny')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(ManualFailoverPrecheckStatus.CANDIDATE_NOT_MEMEBER.value[0].format(candidate='Reality',
|
||||
cluster_name='dummy'),
|
||||
result.output)
|
||||
|
||||
# Specifying wrong leader
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='dummy')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(
|
||||
ManualFailoverPrecheckStatus.LEADER_NOT_MEMBER.value[0].format(leader='dummy',
|
||||
cluster_name='dummy'),
|
||||
result.output)
|
||||
|
||||
mock_get_dcs.return_value.get_cluster = Mock(
|
||||
return_value=get_cluster_initialized_with_leader(sync=('leader', 'other')))
|
||||
|
||||
# Candidate is not a sync standby
|
||||
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=True)):
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\notherMember\n\ny')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(ManualFailoverPrecheckStatus.CANDIDATE_NOT_SYNC_STANDBY.value[0], result.output)
|
||||
|
||||
# No healthy nodes to promote in sync mode
|
||||
mock_get_dcs.return_value.get_cluster = Mock(return_value=get_cluster_initialized_with_leader(sync=('leader')))
|
||||
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=True)):
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--force'])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(ManualFailoverPrecheckStatus.NO_SYNC_CANDIDATE.value[0].format(action='switchover'),
|
||||
result.output)
|
||||
|
||||
# No healthy nodes to promote in async mode
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_only_leader
|
||||
with patch.object(GlobalConfig, 'is_synchronous_mode', PropertyMock(return_value=False)):
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--force'])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(ManualFailoverPrecheckStatus.ONLY_LEADER.value[0].format(action='switchover'),
|
||||
result.output)
|
||||
|
||||
# Cluster has no leader
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_without_leader
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0', '--leader', 'leader', '--force'])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(
|
||||
ManualFailoverPrecheckStatus.CLUSTER_NO_LEADER.value[0].format(leader='leader', cluster_name='dummy'),
|
||||
result.output)
|
||||
|
||||
# [Errors while sending Patroni REST API request]
|
||||
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
|
||||
with patch.object(PoolManager, 'request', Mock(side_effect=Exception)):
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'],
|
||||
input=f'leader\nother\n{self.SCHEDULED_TS}\ny')
|
||||
self.assertIn('falling back to DCS', result.output)
|
||||
|
||||
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)
|
||||
|
||||
mock_api_request.return_value.status = 501
|
||||
mock_api_request.return_value.data = b'Server does not support this operation'
|
||||
result = self.runner.invoke(ctl, ['switchover', 'dummy', '--group', '0'], input='leader\nother\n\ny')
|
||||
self.assertIn('Switchover failed', result.output)
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
@patch.object(PoolManager, 'request', Mock(return_value=MockResponse()))
|
||||
@patch('patroni.ctl.request_patroni', Mock(return_value=MockResponse()))
|
||||
@@ -274,9 +221,8 @@ class TestCtl(unittest.TestCase):
|
||||
# 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(ManualFailoverPrecheckStatus.FAILOVER_NO_CANDIDATE.value[0], result.output)
|
||||
self.assertIn('Failover could be performed only to a specific candidate', result.output)
|
||||
|
||||
# Failover to an async member in sync mode (confirm)
|
||||
cluster = get_cluster_initialized_with_leader(sync=('leader', 'other'))
|
||||
|
||||
# Temp test to check a fallback to switchover if leader is specified
|
||||
@@ -347,11 +293,17 @@ class TestCtl(unittest.TestCase):
|
||||
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()', {})
|
||||
self.assertTrue('No connection to' in str(rows))
|
||||
self.assertTrue('No connection is available' in str(rows))
|
||||
|
||||
rows = query_member({}, None, None, None, 'foo', 'replica', 'SELECT pg_catalog.pg_is_in_recovery()', {})
|
||||
self.assertTrue('No connection to' in str(rows))
|
||||
# Member given -- message pointing to member
|
||||
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()', {})
|
||||
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()', {})
|
||||
@@ -389,9 +341,12 @@ class TestCtl(unittest.TestCase):
|
||||
|
||||
@patch.object(PoolManager, 'request')
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_reinit(self, mock_get_dcs, mock_post):
|
||||
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
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = self.runner.invoke(ctl, ['reinit', 'alpha'], input='y')
|
||||
assert result.exit_code == 1
|
||||
@@ -400,88 +355,67 @@ class TestCtl(unittest.TestCase):
|
||||
result = self.runner.invoke(ctl, ['reinit', 'alpha', 'other'], input='y\ny')
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch.object(PoolManager, 'request')
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_restart(self, mock_get_dcs, mock_post):
|
||||
mock_get_dcs.return_value.get_cluster = get_cluster_initialized_with_leader
|
||||
mock_post.return_value.status = 200
|
||||
|
||||
# Successful restart
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='now\ny\n')
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
# Aborted
|
||||
# Aborted restart
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha'], input='now\nN')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
assert result.exit_code == 1
|
||||
|
||||
# With pending the flag
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pending', '--force'])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Aborted scheduled restart
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--scheduled', '2019-10-01T14:30'], input='N')
|
||||
assert result.exit_code == 1
|
||||
|
||||
# Not a member
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'dummy', '--any'], input='now\ny')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Not a single cluster member among provided members', result.output)
|
||||
|
||||
# Not a member with the specified role
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--role', 'primary'], input='now\ny')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('No primary among provided members', result.output)
|
||||
assert result.exit_code == 1
|
||||
|
||||
# Wrong pg version
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--any', '--pg-version', '9.1'], input='now\ny')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Error: Invalid PostgreSQL version format', result.output)
|
||||
assert 'Error: Invalid PostgreSQL version format' in result.output
|
||||
assert result.exit_code == 1
|
||||
|
||||
# Restart with timeout
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--pending', '--force', '--timeout', '10min'])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Scheduled restart
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
|
||||
assert 'Failed: flush scheduled restart' in result.output
|
||||
|
||||
# Aborted scheduled restart
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--scheduled', self.SCHEDULED_TS], input='N')
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
|
||||
# Error parsing scheduled flag value (no tz)
|
||||
result = self.runner.invoke(ctl,
|
||||
['restart', 'alpha', 'other', '--force', '--scheduled', self.SCHEDULED_TS_NO_TZ])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn(ParseScheduleErrors.NO_TIMEZONE.value[0].format(action='restart'), result.output)
|
||||
|
||||
# Error parsing scheduled flag value (invalid date)
|
||||
result = self.runner.invoke(ctl,
|
||||
['restart', 'alpha', 'other', '--force', '--scheduled', self.SCHEDULED_TS_INVALID])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn('Unable to parse scheduled timestamp', result.output)
|
||||
|
||||
# Successfully scheduled restart
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', '--scheduled', self.SCHEDULED_TS], input='Y')
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assertIn('Success: restart on member other', result.output)
|
||||
|
||||
# Not possible to schedule in pause mode
|
||||
with patch('patroni.config.GlobalConfig.is_paused', PropertyMock(return_value=True)):
|
||||
result = self.runner.invoke(ctl,
|
||||
['restart', 'alpha', 'other', '--force', '--scheduled', self.SCHEDULED_TS])
|
||||
self.assertEqual(result.exit_code, 1)
|
||||
self.assertIn("Can't schedule restart in the paused state", result.output)
|
||||
['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
|
||||
assert result.exit_code == 1
|
||||
|
||||
# Force restart with restart already scheduled
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force', '--scheduled', self.SCHEDULED_TS])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
# force restart with restart already present
|
||||
result = self.runner.invoke(ctl, ['restart', 'alpha', 'other', '--force', '--scheduled', '2300-10-01T14:30'])
|
||||
assert result.exit_code == 0
|
||||
|
||||
ctl_args = ['restart', 'alpha', '--pg-version', '99.0', '--scheduled', '2300-10-01T14:30']
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
mock_post.return_value.status = 200
|
||||
result = self.runner.invoke(ctl, ctl_args, input='y')
|
||||
assert result.exit_code == 0
|
||||
|
||||
# get restart with the non-200 return code
|
||||
ctl_args = ['restart', 'alpha', '--pg-version', '99.0', '--scheduled', self.SCHEDULED_TS]
|
||||
for code, output in [
|
||||
(204, 'Failed: restart for member other, status code=204'),
|
||||
(202, 'Success: restart scheduled'),
|
||||
(409, 'Failed: another restart is already')
|
||||
]:
|
||||
mock_post.return_value.status = code
|
||||
result = self.runner.invoke(ctl, ctl_args, input='y')
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.assertIn(output, result.output)
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
mock_post.return_value.status = 204
|
||||
result = self.runner.invoke(ctl, ctl_args, input='y')
|
||||
assert result.exit_code == 0
|
||||
|
||||
# get restart with the non-200 return code
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
mock_post.return_value.status = 202
|
||||
result = self.runner.invoke(ctl, ctl_args, input='y')
|
||||
assert 'Success: restart scheduled' in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
# get restart with the non-200 return code
|
||||
# normal restart, the schedule is actually parsed, but not validated in patronictl
|
||||
mock_post.return_value.status = 409
|
||||
result = self.runner.invoke(ctl, ctl_args, input='y')
|
||||
assert 'Failed: another restart is already' in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch('patroni.ctl.get_dcs')
|
||||
def test_remove(self, mock_get_dcs):
|
||||
|
||||
+11
-4
@@ -6,8 +6,8 @@ import urllib3
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs.etcd import DnsCachingResolver
|
||||
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Client, \
|
||||
Etcd3Error, Etcd3ClientError, RetryFailedError, InvalidAuthToken, Unavailable, \
|
||||
Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode
|
||||
Etcd3Error, Etcd3ClientError, ReAuthenticateMode, RetryFailedError, InvalidAuthToken, Unavailable, \
|
||||
Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, AuthOldRevision, base64_encode
|
||||
from threading import Thread
|
||||
|
||||
from . import SleepException, MockResponse
|
||||
@@ -161,9 +161,16 @@ class TestPatroniEtcd3Client(BaseTestEtcd3):
|
||||
mock_urlopen.return_value.content = '{"code":16,"error":"etcdserver: invalid auth token"}'
|
||||
self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo')
|
||||
with patch.object(PatroniEtcd3Client, 'authenticate', Mock(return_value=True)):
|
||||
self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo')
|
||||
retry = self.etcd3._retry.copy()
|
||||
with patch('time.time', Mock(side_effect=[0, 10, 20, 30, 40])):
|
||||
self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry)
|
||||
self.client.username = None
|
||||
self.assertRaises(InvalidAuthToken, self.client.deleteprefix, 'foo')
|
||||
self.client._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||
retry = self.etcd3._retry.copy()
|
||||
self.assertRaises(InvalidAuthToken, retry, self.client.deleteprefix, 'foo', retry=retry)
|
||||
mock_urlopen.return_value.content = '{"code":3,"error":"etcdserver: revision of auth store is old"}'
|
||||
self.client._reauthenticate_reason = ReAuthenticateMode.NOT_REQUIRED
|
||||
self.assertRaises(AuthOldRevision, retry, self.client.deleteprefix, 'foo', retry=retry)
|
||||
|
||||
def test__handle_server_response(self):
|
||||
response = MockResponse()
|
||||
|
||||
+33
-14
@@ -6,7 +6,7 @@ import sys
|
||||
from mock import Mock, MagicMock, PropertyMock, patch, mock_open
|
||||
from patroni.collections import CaseInsensitiveSet
|
||||
from patroni.config import Config
|
||||
from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, SyncState, TimelineHistory
|
||||
from patroni.dcs import Cluster, ClusterConfig, Failover, Leader, Member, get_dcs, Status, SyncState, TimelineHistory
|
||||
from patroni.dcs.etcd import AbstractEtcdClientWithFailover
|
||||
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniFatalException
|
||||
from patroni.ha import Ha, _MemberStatus
|
||||
@@ -39,7 +39,7 @@ def get_cluster(initialize, leader, members, failover, sync, cluster_config=None
|
||||
history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '","foo"]]',
|
||||
[(1, 67197376, 'no recovery target specified', t, 'foo')])
|
||||
cluster_config = cluster_config or ClusterConfig(1, {'check_timeline': True}, 1)
|
||||
return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history, None, failsafe)
|
||||
return Cluster(initialize, cluster_config, leader, Status(10, None), members, failover, sync, history, failsafe)
|
||||
|
||||
|
||||
def get_cluster_not_initialized_without_leader(cluster_config=None):
|
||||
@@ -94,11 +94,12 @@ def get_cluster_initialized_with_leader_and_failsafe():
|
||||
|
||||
def get_node_status(reachable=True, in_recovery=True, dcs_last_seen=0,
|
||||
timeline=2, wal_position=10, nofailover=False,
|
||||
watchdog_failed=False):
|
||||
watchdog_failed=False, failover_priority=1):
|
||||
def fetch_node_status(e):
|
||||
tags = {}
|
||||
if nofailover:
|
||||
tags['nofailover'] = True
|
||||
tags['failover_priority'] = failover_priority
|
||||
return _MemberStatus(e, reachable, in_recovery, wal_position,
|
||||
{'tags': tags, 'watchdog_failed': watchdog_failed,
|
||||
'dcs_last_seen': dcs_last_seen, 'timeline': timeline})
|
||||
@@ -153,6 +154,7 @@ zookeeper:
|
||||
'postmaster_start_time': str(postmaster_start_time)}
|
||||
self.watchdog = Watchdog(self.config)
|
||||
self.request = lambda *args, **kwargs: requests_get(args[0].api_url, *args[1:], **kwargs)
|
||||
self.failover_priority = 1
|
||||
|
||||
|
||||
def run_async(self, func, args=()):
|
||||
@@ -167,6 +169,7 @@ def run_async(self, func, args=()):
|
||||
@patch.object(Postgresql, 'is_primary', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'timeline_wal_position', Mock(return_value=(1, 10, 1)))
|
||||
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=10))
|
||||
@patch.object(Postgresql, 'slots', Mock(return_value={'l': 100}))
|
||||
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
|
||||
@patch.object(Postgresql, 'controldata', Mock(return_value={
|
||||
'Database system identifier': SYSID,
|
||||
@@ -1035,6 +1038,11 @@ class TestHa(PostgresInit):
|
||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
self.ha.fetch_node_status = get_node_status(in_recovery=False) # accessible, not in_recovery
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
self.ha.fetch_node_status = get_node_status(failover_priority=2) # accessible, in_recovery, higher priority
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
# if there is a higher-priority node but it has a lower WAL position then this node should race
|
||||
self.ha.fetch_node_status = get_node_status(failover_priority=6, wal_position=9)
|
||||
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
self.ha.fetch_node_status = get_node_status(wal_position=11) # accessible, in_recovery, wal position ahead
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
# in synchronous_mode consider itself healthy if the former leader is accessible in read-only and ahead of us
|
||||
@@ -1050,7 +1058,9 @@ class TestHa(PostgresInit):
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
self.ha.patroni.nofailover = True
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
self.ha.patroni.nofailover = False
|
||||
self.ha.patroni.nofailover = None
|
||||
self.ha.patroni.failover_priority = 0
|
||||
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
|
||||
|
||||
def test_fetch_node_status(self):
|
||||
member = Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni'})
|
||||
@@ -1478,6 +1488,24 @@ class TestHa(PostgresInit):
|
||||
self.ha.run_cycle()
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Updating sync state failed')
|
||||
|
||||
@patch.object(Cluster, 'is_unlocked', Mock(return_value=False))
|
||||
def test_inconsistent_synchronous_state(self):
|
||||
self.ha.is_synchronous_mode = true
|
||||
self.ha.has_lock = true
|
||||
self.p.name = 'leader'
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(sync=('leader', 'a'))
|
||||
self.p.sync_handler.current_state = Mock(return_value=(CaseInsensitiveSet('a'), CaseInsensitiveSet()))
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=SyncState.empty())
|
||||
mock_set_sync = self.p.sync_handler.set_synchronous_standby_names = Mock()
|
||||
with patch('patroni.ha.logger.warning') as mock_logger:
|
||||
self.ha.run_cycle()
|
||||
mock_set_sync.assert_called_once()
|
||||
self.assertTrue(mock_logger.call_args_list[0][0][0].startswith('Inconsistent state between '))
|
||||
self.ha.dcs.write_sync_state = Mock(return_value=None)
|
||||
with patch('patroni.ha.logger.warning') as mock_logger:
|
||||
self.ha.run_cycle()
|
||||
self.assertEqual(mock_logger.call_args[0][0], 'Updating sync state failed')
|
||||
|
||||
def test_effective_tags(self):
|
||||
self.ha._disable_sync = True
|
||||
self.assertEqual(self.ha.get_effective_tags(), {'foo': 'bar', 'nosync': True})
|
||||
@@ -1564,6 +1592,7 @@ class TestHa(PostgresInit):
|
||||
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
def test_permanent_logical_slots_after_promote(self):
|
||||
self.p._major_version = 110000
|
||||
config = ClusterConfig(1, {'slots': {'l': {'database': 'postgres', 'plugin': 'test_decoding'}}}, 1)
|
||||
self.p.name = 'other'
|
||||
self.ha.cluster = get_cluster_initialized_without_leader(cluster_config=config)
|
||||
@@ -1630,13 +1659,3 @@ class TestHa(PostgresInit):
|
||||
self.assertEqual(self.ha.patroni.request.call_args[1]['timeout'], 2)
|
||||
mock_logger.assert_called()
|
||||
self.assertTrue(mock_logger.call_args[0][0].startswith('Request to Citus coordinator'))
|
||||
|
||||
def test_has_members_eligible_to_promote(self):
|
||||
self.ha.fetch_node_status = get_node_status()
|
||||
members = [
|
||||
Member(0, 'test', 1, {'api_url': 'http://127.0.0.1:8011/patroni', 'conn_url': 'postgres://127.0.0.1:5432/postgres'}),
|
||||
Member(0, 'test2', 1, {'api_url': 'http://127.0.0.1:8011/patroni', 'conn_url': 'postgres://127.0.0.1:5432/postgres'}),
|
||||
]
|
||||
with patch('patroni.ha.logger.info') as mock_logger:
|
||||
self.assertTrue(self.ha.has_members_eligible_to_promote(members, fast_path=True))
|
||||
mock_logger.assert_not_called()
|
||||
|
||||
@@ -63,7 +63,7 @@ def mock_list_namespaced_pod(*args, **kwargs):
|
||||
metadata = k8s_client.V1ObjectMeta(resource_version='1', labels={'f': 'b', Kubernetes._CITUS_LABEL: '1'},
|
||||
name='p-0', annotations={'status': '{}'},
|
||||
uid='964dfeae-e79b-4476-8a5a-1920b5c2a69d')
|
||||
status = k8s_client.V1PodStatus(pod_ip='10.0.0.0')
|
||||
status = k8s_client.V1PodStatus(pod_ip='10.0.0.1')
|
||||
spec = k8s_client.V1PodSpec(hostname='p-0', node_name='kind-control-plane', containers=[])
|
||||
items = [k8s_client.V1Pod(metadata=metadata, status=status, spec=spec)]
|
||||
return k8s_client.V1PodList(items=items, kind='PodList')
|
||||
@@ -356,6 +356,20 @@ class TestKubernetesConfigMaps(BaseTestKubernetes):
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
class TestKubernetesEndpointsNoPodIP(BaseTestKubernetes):
|
||||
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True)
|
||||
def setUp(self, config=None):
|
||||
super(TestKubernetesEndpointsNoPodIP, self).setUp({'use_endpoints': True})
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True)
|
||||
def test_update_leader(self, mock_patch_namespaced_endpoints):
|
||||
leader = self.k.get_cluster().leader
|
||||
self.assertIsNotNone(self.k.update_leader(leader, '123', failsafe={'foo': 'bar'}))
|
||||
args = mock_patch_namespaced_endpoints.call_args[0]
|
||||
self.assertEqual(args[2].subsets[0].addresses[0].target_ref.resource_version, '1')
|
||||
self.assertEqual(args[2].subsets[0].addresses[0].ip, '10.0.0.1')
|
||||
|
||||
|
||||
class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
|
||||
@patch.object(k8s_client.CoreV1Api, 'list_namespaced_endpoints', mock_list_namespaced_endpoints, create=True)
|
||||
@@ -368,6 +382,7 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
|
||||
self.assertIsNotNone(self.k.update_leader(leader, '123', failsafe={'foo': 'bar'}))
|
||||
args = mock_patch_namespaced_endpoints.call_args[0]
|
||||
self.assertEqual(args[2].subsets[0].addresses[0].target_ref.resource_version, '10')
|
||||
self.assertEqual(args[2].subsets[0].addresses[0].ip, '10.0.0.0')
|
||||
self.k._kinds._object_cache['test'].subsets[:] = []
|
||||
self.assertIsNotNone(self.k.update_leader(leader, '123'))
|
||||
self.k._kinds._object_cache['test'].metadata.annotations['leader'] = 'p-1'
|
||||
|
||||
+52
-12
@@ -15,8 +15,7 @@ from patroni.dcs.etcd import AbstractEtcdClientWithFailover
|
||||
from patroni.exceptions import DCSError
|
||||
from patroni.postgresql import Postgresql
|
||||
from patroni.postgresql.config import ConfigHandler
|
||||
from patroni import check_psycopg
|
||||
from patroni.__main__ import Patroni, main as _main
|
||||
from patroni.__main__ import check_psycopg, Patroni, main as _main
|
||||
from threading import Thread
|
||||
|
||||
from . import psycopg_connect, SleepException
|
||||
@@ -25,10 +24,16 @@ from .test_postgresql import MockPostmaster
|
||||
|
||||
|
||||
def mock_import(*args, **kwargs):
|
||||
if args[0] == 'psycopg':
|
||||
ret = Mock()
|
||||
ret.__version__ = '2.5.3.dev1 a b c' if args[0] == 'psycopg2' else '3.1.0'
|
||||
return ret
|
||||
|
||||
|
||||
def mock_import2(*args, **kwargs):
|
||||
if args[0] == 'psycopg2':
|
||||
raise ImportError
|
||||
ret = Mock()
|
||||
ret.__version__ = '2.5.3.dev1 a b c'
|
||||
ret.__version__ = '0.1.2'
|
||||
return ret
|
||||
|
||||
|
||||
@@ -40,7 +45,7 @@ class MockFrozenImporter(object):
|
||||
@patch('time.sleep', Mock())
|
||||
@patch('subprocess.call', Mock(return_value=0))
|
||||
@patch('patroni.psycopg.connect', psycopg_connect)
|
||||
@patch('urllib3.PoolManager.request', Mock(side_effect=Exception))
|
||||
@patch('urllib3.connection.HTTPConnection.connect', Mock(side_effect=Exception))
|
||||
@patch.object(ConfigHandler, 'append_pg_hba', Mock())
|
||||
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
|
||||
@patch.object(ConfigHandler, 'write_recovery_conf', Mock())
|
||||
@@ -64,7 +69,7 @@ class TestPatroni(unittest.TestCase):
|
||||
self.assertRaises(SystemExit, _main)
|
||||
|
||||
@patch('pkgutil.iter_importers', Mock(return_value=[MockFrozenImporter()]))
|
||||
@patch('urllib3.PoolManager.request', Mock(side_effect=Exception))
|
||||
@patch('urllib3.connection.HTTPConnection.connect', Mock(side_effect=Exception))
|
||||
@patch('sys.frozen', Mock(return_value=True), create=True)
|
||||
@patch.object(HTTPServer, '__init__', Mock())
|
||||
@patch.object(etcd.Client, 'read', etcd_read)
|
||||
@@ -108,6 +113,7 @@ class TestPatroni(unittest.TestCase):
|
||||
@patch('os.getpid')
|
||||
@patch('multiprocessing.Process')
|
||||
@patch('patroni.__main__.patroni_main', Mock())
|
||||
@patch('sys.argv', ['patroni.py', 'postgres0.yml'])
|
||||
def test_patroni_main(self, mock_process, mock_getpid):
|
||||
mock_getpid.return_value = 2
|
||||
_main()
|
||||
@@ -173,10 +179,42 @@ class TestPatroni(unittest.TestCase):
|
||||
self.assertTrue(self.p.noloadbalance)
|
||||
|
||||
def test_nofailover(self):
|
||||
self.p.tags['nofailover'] = True
|
||||
self.assertTrue(self.p.nofailover)
|
||||
self.p.tags['nofailover'] = None
|
||||
self.assertFalse(self.p.nofailover)
|
||||
for (nofailover, failover_priority, expected) in [
|
||||
# Without any tags, default is False
|
||||
(None, None, False),
|
||||
# Setting `nofailover: True` has precedence
|
||||
(True, 0, True),
|
||||
(True, 1, True),
|
||||
# Similarly, setting `nofailover: False` has precedence
|
||||
(False, 0, False),
|
||||
(False, 1, False),
|
||||
# Only when we have `nofailover: None` should we got based on priority
|
||||
(None, 0, True),
|
||||
(None, 1, False),
|
||||
]:
|
||||
with self.subTest(nofailover=nofailover, failover_priority=failover_priority, expected=expected):
|
||||
self.p.tags['nofailover'] = nofailover
|
||||
self.p.tags['failover_priority'] = failover_priority
|
||||
self.assertEqual(self.p.nofailover, expected)
|
||||
|
||||
def test_failover_priority(self):
|
||||
for (nofailover, failover_priority, expected) in [
|
||||
# Without any tags, default is 1
|
||||
(None, None, 1),
|
||||
# Setting `nofailover: True` has precedence (value 0)
|
||||
(True, 0, 0),
|
||||
(True, 1, 0),
|
||||
# Setting `nofailover: False` and `failover_priority: None` gives 1
|
||||
(False, None, 1),
|
||||
# Normal function of failover_priority
|
||||
(None, 0, 0),
|
||||
(None, 1, 1),
|
||||
(None, 2, 2),
|
||||
]:
|
||||
with self.subTest(nofailover=nofailover, failover_priority=failover_priority, expected=expected):
|
||||
self.p.tags['nofailover'] = nofailover
|
||||
self.p.tags['failover_priority'] = failover_priority
|
||||
self.assertEqual(self.p.failover_priority, expected)
|
||||
|
||||
def test_replicatefrom(self):
|
||||
self.assertIsNone(self.p.replicatefrom)
|
||||
@@ -204,6 +242,8 @@ class TestPatroni(unittest.TestCase):
|
||||
with patch('builtins.__import__', Mock(side_effect=ImportError)):
|
||||
self.assertRaises(SystemExit, check_psycopg)
|
||||
with patch('builtins.__import__', mock_import):
|
||||
self.assertIsNone(check_psycopg())
|
||||
with patch('builtins.__import__', mock_import2):
|
||||
self.assertRaises(SystemExit, check_psycopg)
|
||||
|
||||
def test_ensure_unique_name(self):
|
||||
@@ -233,8 +273,8 @@ class TestPatroni(unittest.TestCase):
|
||||
)
|
||||
with patch('patroni.dcs.AbstractDCS.get_cluster', Mock(return_value=bad_cluster)):
|
||||
# If the api of the running node cannot be reached, this implies unique name
|
||||
with patch.object(self.p, 'request', Mock(side_effect=ConnectionError)):
|
||||
with patch('urllib3.connection.HTTPConnection.connect', Mock(side_effect=ConnectionError)):
|
||||
self.assertIsNone(self.p.ensure_unique_name())
|
||||
# Only if the api of the running node is reachable do we throw an error
|
||||
with patch.object(self.p, 'request', Mock()):
|
||||
with patch('urllib3.connection.HTTPConnection.connect', Mock()):
|
||||
self.assertRaises(SystemExit, self.p.ensure_unique_name)
|
||||
|
||||
@@ -688,7 +688,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.assertIsNone(self.p.wait_for_startup())
|
||||
|
||||
def test_get_server_parameters(self):
|
||||
config = {'parameters': {'wal_level': 'hot_standby'}, 'listen': '0'}
|
||||
config = {'parameters': {'wal_level': 'hot_standby', 'max_prepared_transactions': 100}, 'listen': '0'}
|
||||
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})
|
||||
@@ -721,6 +721,7 @@ class TestPostgresql(BaseTestPostgresql):
|
||||
self.assertEqual(self.p.get_primary_timeline(), 1)
|
||||
|
||||
@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',
|
||||
@@ -964,6 +965,7 @@ class TestPostgresql2(BaseTestPostgresql):
|
||||
@patch('patroni.postgresql.CallbackExecutor', Mock())
|
||||
@patch.object(Postgresql, 'get_major_version', Mock(return_value=140000))
|
||||
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
|
||||
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
||||
def setUp(self):
|
||||
super(TestPostgresql2, self).setUp()
|
||||
|
||||
|
||||
+36
-16
@@ -8,7 +8,7 @@ from threading import Thread
|
||||
|
||||
from patroni import psycopg
|
||||
from patroni.config import GlobalConfig
|
||||
from patroni.dcs import Cluster, ClusterConfig, Member, SyncState
|
||||
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
|
||||
@@ -33,15 +33,15 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
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, 0, [self.me, self.other, self.leadermem],
|
||||
None, SyncState.empty(), None, {'ls': 12345, 'ls2': 12345}, None)
|
||||
self.cluster = Cluster(True, config, self.leader, Status(0, {'ls': 12345, 'ls2': 12345}),
|
||||
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
|
||||
|
||||
def test_sync_replication_slots(self):
|
||||
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
|
||||
'A': 0, 'ls': 0, 'b': {'type': 'logical', 'plugin': '1'}},
|
||||
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
||||
cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem],
|
||||
None, SyncState.empty(), None, {'test_3': 10}, None)
|
||||
cluster = Cluster(True, config, self.leader, Status(0, {'test_3': 10}),
|
||||
[self.me, self.other, self.leadermem], None, SyncState.empty(), None, None)
|
||||
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg.OperationalError)):
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
self.p.set_role('standby_leader')
|
||||
@@ -53,6 +53,7 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
self.p.set_role('replica')
|
||||
with patch.object(Postgresql, 'is_primary', Mock(return_value=False)), \
|
||||
patch.object(SlotsHandler, 'drop_replication_slot') as mock_drop:
|
||||
config.data['slots'].pop('ls')
|
||||
self.s.sync_replication_slots(cluster, False, paused=True)
|
||||
mock_drop.assert_not_called()
|
||||
self.p.set_role('primary')
|
||||
@@ -69,6 +70,8 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
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, False)
|
||||
self.p.set_role('replica')
|
||||
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."""
|
||||
@@ -77,21 +80,20 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
'state': 'running', 'conn_url': 'postgres://replicator:[email protected]:5436/postgres',
|
||||
'tags': {'replicatefrom': 'postgresql0'}
|
||||
})
|
||||
cluster = Cluster(True, config, self.leader, 0,
|
||||
[self.me, self.other, self.leadermem, cascading_replica],
|
||||
None, SyncState.empty(), None, {'ls': 10}, None)
|
||||
cluster = Cluster(True, config, self.leader, Status(0, {'ls': 10}),
|
||||
[self.me, self.other, self.leadermem, cascading_replica], None, SyncState.empty(), None, None)
|
||||
self.p.set_role('replica')
|
||||
with patch.object(Postgresql, '_query') as mock_query, \
|
||||
patch.object(Postgresql, 'is_primary', Mock(return_value=False)):
|
||||
mock_query.return_value = [('ls', 'logical', 'b', 'a', 5, 12345, 105)]
|
||||
mock_query.return_value = [('ls', 'logical', 104, 'b', 'a', 5, 12345, 105)]
|
||||
ret = self.s.sync_replication_slots(cluster, False)
|
||||
self.assertEqual(ret, [])
|
||||
|
||||
def test_process_permanent_slots(self):
|
||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}},
|
||||
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}, 'blabla': {'type': 'physical'}},
|
||||
'ignore_slots': [{'name': 'blabla'}]}, 1)
|
||||
cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem],
|
||||
None, SyncState.empty(), None, None, None)
|
||||
cluster = Cluster(True, config, self.leader, Status.empty(), [self.me, self.other, self.leadermem],
|
||||
None, SyncState.empty(), None, None)
|
||||
|
||||
self.s.sync_replication_slots(cluster, False)
|
||||
with patch.object(Postgresql, '_query') as mock_query:
|
||||
@@ -99,8 +101,10 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
mock_query.return_value = [(
|
||||
1, 0, 0, 0, 0, 0, 0, 0, 0, None, None,
|
||||
[{"slot_name": "ls", "type": "logical", "datoid": 5, "plugin": "b",
|
||||
"confirmed_flush_lsn": 12345, "catalog_xmin": 105}])]
|
||||
self.assertEqual(self.p.slots(), {'ls': 12345})
|
||||
"confirmed_flush_lsn": 12345, "catalog_xmin": 105, "restart_lsn": 12344},
|
||||
{"slot_name": "blabla", "type": "physical", "datoid": None, "plugin": None,
|
||||
"confirmed_flush_lsn": None, "catalog_xmin": 105, "restart_lsn": 12344}])]
|
||||
self.assertEqual(self.p.slots(), {'ls': 12345, 'blabla': 12344})
|
||||
|
||||
self.p.reset_cluster_info_state(None)
|
||||
mock_query.return_value = [(
|
||||
@@ -115,8 +119,8 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
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, False), [])
|
||||
self.s._schedule_load_slots = False
|
||||
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)), \
|
||||
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')
|
||||
@@ -124,6 +128,7 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
self.cluster.slots['ls'] = 'a'
|
||||
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, False), ['ls'])
|
||||
|
||||
@@ -178,3 +183,18 @@ class TestSlotsHandler(BaseTestPostgresql):
|
||||
with patch.object(SlotsHandler, 'get_local_connection_cursor', Mock(side_effect=Exception)):
|
||||
self.s.schedule_advance_slots({'foo': {'bar': 100}})
|
||||
self.s._advance.sync_slots()
|
||||
|
||||
@patch.object(Postgresql, 'is_primary', Mock(return_value=False))
|
||||
def test_advance_physical_slots(self):
|
||||
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)
|
||||
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, 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],
|
||||
"Error while advancing replication slot %s to position '%s': %r")
|
||||
|
||||
@@ -134,6 +134,23 @@ def connect_side_effect(host_port):
|
||||
raise socket.gaierror()
|
||||
|
||||
|
||||
def mock_getaddrinfo(host, port, *args):
|
||||
if port is None or port == "":
|
||||
port = 0
|
||||
port = int(port)
|
||||
if port not in range(0, 65536):
|
||||
raise socket.gaierror()
|
||||
|
||||
if host == "127.0.0.1" or host == "" or host is None:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('127.0.0.1', port))]
|
||||
elif host == "127.0.0.2":
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('127.0.0.2', port))]
|
||||
elif host == "::1":
|
||||
return [(socket.AF_INET6, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('::1', port, 0, 0))]
|
||||
else:
|
||||
raise socket.gaierror()
|
||||
|
||||
|
||||
def parse_output(output):
|
||||
result = []
|
||||
for s in output.split("\n"):
|
||||
@@ -145,6 +162,7 @@ def parse_output(output):
|
||||
|
||||
|
||||
@patch('socket.socket.connect_ex', Mock(side_effect=connect_side_effect))
|
||||
@patch('socket.getaddrinfo', Mock(side_effect=mock_getaddrinfo))
|
||||
@patch('os.path.exists', Mock(side_effect=exists_side_effect))
|
||||
@patch('os.path.isdir', Mock(side_effect=isdir_side_effect))
|
||||
@patch('os.path.isfile', Mock(side_effect=isfile_side_effect))
|
||||
@@ -307,3 +325,35 @@ class TestValidator(unittest.TestCase):
|
||||
output = "\n".join(errors)
|
||||
self.assertEqual(['postgresql.bin_dir', 'postgresql.bin_name.postgres', 'raft.bind_addr', 'raft.self_addr'],
|
||||
parse_output(output))
|
||||
|
||||
def test_one_of(self, _, __):
|
||||
c = copy.deepcopy(config)
|
||||
# Providing neither is fine
|
||||
del c["tags"]["nofailover"]
|
||||
errors = schema(c)
|
||||
self.assertNotIn("tags Multiple of ('nofailover', 'failover_priority') provided", errors)
|
||||
# Just nofailover is fine
|
||||
c["tags"]["nofailover"] = False
|
||||
errors = schema(c)
|
||||
self.assertNotIn("tags Multiple of ('nofailover', 'failover_priority') provided", errors)
|
||||
# Just failover_priority is fine
|
||||
del c["tags"]["nofailover"]
|
||||
c["tags"]["failover_priority"] = 1
|
||||
errors = schema(c)
|
||||
self.assertNotIn("tags Multiple of ('nofailover', 'failover_priority') provided", errors)
|
||||
# Providing both is not fine
|
||||
c["tags"]["nofailover"] = False
|
||||
errors = schema(c)
|
||||
self.assertIn("tags Multiple of ('nofailover', 'failover_priority') provided", errors)
|
||||
|
||||
def test_failover_priority_int(self, *args):
|
||||
c = copy.deepcopy(config)
|
||||
del c["tags"]["nofailover"]
|
||||
c["tags"]["failover_priority"] = 'a string'
|
||||
errors = schema(c)
|
||||
self.assertIn('tags.failover_priority a string is not an integer', errors)
|
||||
c = copy.deepcopy(config)
|
||||
del c["tags"]["nofailover"]
|
||||
c["tags"]["failover_priority"] = -6
|
||||
errors = schema(c)
|
||||
self.assertIn('tags.failover_priority -6 didn\'t pass validation: Wrong value', errors)
|
||||
|
||||
+8
-15
@@ -1,13 +1,13 @@
|
||||
import select
|
||||
import unittest
|
||||
|
||||
from kazoo.client import KazooClient, KazooState
|
||||
from kazoo.client import KazooClient
|
||||
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||
from kazoo.handlers.threading import SequentialThreadingHandler
|
||||
from kazoo.protocol.states import KeeperState, ZnodeStat
|
||||
from kazoo.protocol.states import KeeperState, WatchedEvent, ZnodeStat
|
||||
from kazoo.retry import RetryFailedError
|
||||
from mock import Mock, PropertyMock, patch
|
||||
from patroni.dcs.zookeeper import Cluster, Leader, PatroniKazooClient, \
|
||||
from patroni.dcs.zookeeper import Cluster, PatroniKazooClient, \
|
||||
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
|
||||
|
||||
|
||||
@@ -152,9 +152,6 @@ class TestZooKeeper(unittest.TestCase):
|
||||
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
|
||||
'set_acls': {'CN=principal2': ['ALL']}})
|
||||
|
||||
def test_session_listener(self):
|
||||
self.zk.session_listener(KazooState.SUSPENDED)
|
||||
|
||||
def test_reload_config(self):
|
||||
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 10})
|
||||
self.zk.reload_config({'ttl': 20, 'retry_timeout': 10, 'loop_wait': 5})
|
||||
@@ -176,15 +173,6 @@ class TestZooKeeper(unittest.TestCase):
|
||||
self.zk._cluster_loader(self.zk.client_path(''))
|
||||
|
||||
def test_get_cluster(self):
|
||||
cluster = self.zk.get_cluster(True)
|
||||
self.assertIsInstance(cluster.leader, Leader)
|
||||
self.zk.status_watcher(None)
|
||||
self.zk.get_cluster()
|
||||
self.zk.touch_member({'foo': 'foo'})
|
||||
self.zk._name = 'bar'
|
||||
self.zk.status_watcher(None)
|
||||
with patch.object(ZooKeeper, 'get_node', Mock(side_effect=Exception)):
|
||||
self.zk.get_cluster()
|
||||
cluster = self.zk.get_cluster()
|
||||
self.assertEqual(cluster.last_lsn, 500)
|
||||
|
||||
@@ -276,6 +264,7 @@ class TestZooKeeper(unittest.TestCase):
|
||||
self.assertTrue(self.zk.delete_cluster())
|
||||
|
||||
def test_watch(self):
|
||||
self.zk.event.wait = Mock()
|
||||
self.zk.watch(None, 0)
|
||||
self.zk.event.is_set = Mock(return_value=True)
|
||||
self.zk._fetch_status = False
|
||||
@@ -294,3 +283,7 @@ class TestZooKeeper(unittest.TestCase):
|
||||
|
||||
def test_set_history_value(self):
|
||||
self.zk.set_history_value('{}')
|
||||
|
||||
def test_watcher(self):
|
||||
self.zk._watcher(WatchedEvent('', '', ''))
|
||||
self.assertTrue(self.zk.watch(1, 1))
|
||||
|
||||
@@ -6,6 +6,7 @@ postgres_matrix =
|
||||
pg13: PG_MAJOR = 13
|
||||
pg14: PG_MAJOR = 14
|
||||
pg15: PG_MAJOR = 15
|
||||
pg16: PG_MAJOR = 16
|
||||
psycopg_deps =
|
||||
py{37,38,39,310,311}-{lin,win}: psycopg[binary]
|
||||
mac: psycopg2-binary
|
||||
@@ -106,7 +107,7 @@ description = Reformat code with black
|
||||
deps = black
|
||||
commands = black {posargs:patroni tests}
|
||||
|
||||
[testenv:pg{12,13,14,15}-docker-build]
|
||||
[testenv:pg{12,13,14,15,16}-docker-build]
|
||||
description = Build docker containers needed for testing
|
||||
labels =
|
||||
behave
|
||||
@@ -124,16 +125,17 @@ commands =
|
||||
--file features/Dockerfile
|
||||
allowlist_externals = docker
|
||||
|
||||
[testenv:pg{12,13,14,15}-docker-behave-{etcd}-{lin,mac}]
|
||||
[testenv:pg{12,13,14,15,16}-docker-behave-{etcd,etcd3}-{lin,mac}]
|
||||
description = Run behaviour tests in patroni-dev docker container
|
||||
setenv =
|
||||
etcd: DCS=etcd
|
||||
etcd3: DCS=etcd3
|
||||
{[common]postgres_matrix}
|
||||
CONTAINER_NAME = tox-{env_name}-{env:PYTHONHASHSEED}
|
||||
labels =
|
||||
behave
|
||||
depends =
|
||||
pg{11,12,13,14,15}-docker-build
|
||||
pg{11,12,13,14,15,16}-docker-build
|
||||
|
||||
# There's a bug which affects calling multiple envs on the command line
|
||||
# This should be a valid command: tox -e 'py{36,37,38,39,310,311}-behave-{env:DCS}-lin'
|
||||
@@ -148,7 +150,7 @@ commands =
|
||||
--tty \
|
||||
{env:PATRONI_DEV_IMAGE:patroni-dev:{env:PG_MAJOR}} \
|
||||
tox run -x 'tox.env_list=py{[common]python_matrix}-behave-{env:DCS}-lin' \
|
||||
-- --format plain {posargs}
|
||||
-- {posargs}
|
||||
|
||||
allowlist_externals =
|
||||
docker
|
||||
@@ -158,7 +160,7 @@ platform =
|
||||
; win: win32
|
||||
mac: darwin
|
||||
|
||||
[testenv:py{36,38,39,310,311}-behave-{etcd}-{lin,win,mac}]
|
||||
[testenv:py{36,38,39,310,311}-behave-{etcd,etcd3}-{lin,win,mac}]
|
||||
description = Run behaviour tests (locally with tox)
|
||||
deps =
|
||||
-r requirements.txt
|
||||
@@ -166,11 +168,15 @@ deps =
|
||||
coverage
|
||||
{[common]psycopg_deps}
|
||||
setenv =
|
||||
DCS = {env:DCS:etcd}
|
||||
etcd: DCS = {env:DCS:etcd}
|
||||
etcd3: DCS = {env:DCS:etcd3}
|
||||
passenv =
|
||||
ETCD_UNSUPPORTED_ARCH
|
||||
commands =
|
||||
python3 -m behave {posargs}
|
||||
python3 -m behave --format json --format plain --outfile result.json {posargs}
|
||||
mv result.json features/output
|
||||
allowlist_externals =
|
||||
mv
|
||||
platform =
|
||||
{[common]platforms}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user