Compare commits

...
54 Commits
Author SHA1 Message Date
Alexander KukushkinandGitHub 333d41d9f0 Release 2.1.3 (#2219)
* Implement missing unit-tests
* Bump version
* Update release notes
2022-02-18 14:16:15 +01:00
Alexander KukushkinandGitHub aa91557a80 Fix bug in divergence timeline check (#2221)
Patroni was falsely assuming that timelines have diverged.
For pg_rewind it didn't create any problem, but if pg_rewind is not allowed and the `remove_data_directory_on_diverged_timelines` is set, it resulted in reinitializing the former leader.

Close https://github.com/zalando/patroni/issues/2220
2022-02-17 15:53:13 +01:00
Hrvoje MilkovićandGitHub 075918d447 Fixed AttributeError no attribute 'leader' (#2217)
Close https://github.com/zalando/patroni/issues/2218
2022-02-16 10:20:15 +01:00
Michael BanckandGitHub c4535ae208 Avoid running CHECKPOINT on remote master if credentials are missing (#2195)
Close #2194
2022-02-14 15:21:51 +01:00
Bastien WirtzandGitHub 38d84b1d15 Make sure no substitution attemps is made when params is empty. (#2212)
Close #2209
2022-02-14 15:20:38 +01:00
Michael BanckandGitHub 2d15e0dae6 Add target_session_attrs=read-write to standby_leader primary_conninfo (#2193)
This allows to have multiple hosts in a standby_cluster and ensures that the standby leader follows the main cluster's new leader after a switchover.

Partially addresses #2189
2022-02-10 15:50:14 +01:00
Michael BanckandGitHub 48d8c13e6b Write pgpass line per host if more than one is specified in connstr (#2192)
Partly addresses #2189
2022-02-10 15:40:24 +01:00
Alexander KukushkinandGitHub d3e3b4e16f Minor tuning of tests (#2201)
- Reduce verbosity for unit tests
- Refactor GH actions config and try again macos behave tests
2022-02-10 15:38:16 +01:00
Alexandre PereiraandGitHub afab392ead Add metrics (#2199)
This PR adds metrics for additional information : 
  - If a node or cluster is pending restart,
  - If the cluster management is paused. 

This may be useful for Prometheus/Grafana monitoring.
Close #2198
2022-02-10 15:37:14 +01:00
Alexander KukushkinandGitHub 291754eeb0 Don't remove the leader lock while paused (#2187)
Close https://github.com/zalando/patroni/issues/2179
2022-02-10 15:36:25 +01:00
Alexander KukushkinandGitHub cdc80a1d89 Restart etcd3 watcher if all etcd nodes don't respond (#2186)
Close https://github.com/zalando/patroni/issues/2180
2022-02-10 15:32:29 +01:00
Alexander KukushkinandGitHub 04c6f58b2b Make Kubernetes.cancel_initialization() method similar to other DCS (#2210)
I.e., do delete unconditionally and return the success
2022-02-10 15:29:29 +01:00
Ants AasmaandGitHub 0980838cb3 Fix port in use error on certificate replacement (#2185)
When switching certificates there is a race condition with a concurrent API request. If there is one active during the replacement period then the replacement will error out with a port in use error and Patroni gets stuck in a state without an active API server.

Fix is to call server_close after shutdown which will wait for already running requests to complete before returning.

Close #2184
2022-01-26 13:52:25 +01:00
Alexander KukushkinandGitHub 3e1076a574 Use replication credentials when checking leader status (#2165)
It could be that `remove_data_directory_on_diverged_timelines` is set, but there is no `rewind_credentials` defined and superuser access between nodes is not allowed.

Close https://github.com/zalando/patroni/issues/2162
2022-01-11 16:23:13 +01:00
Alexander KukushkinandGitHub cb3071adfb Annual cleanup (#2159)
-  Simplify setup.py: remove unneeded features and get rid of deprecation warnings
-  Compatibility with Python 3.10: handle `threading.Event.isSet()` deprecation
-  Make sure setup.py could run without `six`: move Patroni class and main function to the `__main__.py`. The `__init__.py` will have only a few functions used by the Patroni class and from the setup.py
2022-01-06 10:20:31 +01:00
Alexander KukushkinandGitHub bf354aeebd Compatibility with legacy psycopg2 (#2158)
For example, psycopg2 installed from Ubuntu 18.04 packages doesn't have `UndefinedFile` exception yet.
2022-01-06 10:14:50 +01:00
Alexander KukushkinandGitHub 01d40a4a13 Compatibility with latest psutil and setuptools (#2155)
Issues don't affect Patroni code, only unit-tests
2022-01-05 09:53:33 +01:00
Alexander KukushkinandGitHub 3cc14cc059 Unquote integers in validator (#2154)
Close https://github.com/zalando/patroni/issues/2150
2022-01-04 10:47:02 +01:00
Alexander KukushkinandGitHub a015e0e271 Fix bug with failover to cascading standby (#2138)
When figuring out which slots should be created on cascading standby we forgot to take into account that the leader might be absent.

Close: https://github.com/zalando/patroni/issues/2137
2021-12-21 11:20:35 +01:00
Alexander KukushkinandGitHub d2b681b07e Fix bug in the bootstrap standby-leader (#2144)
When starting postgres after bootstrap of the standby-leader the `follow()` method is used to always return `True`.
This behavior was changed in the #2054 in order to avoid hammering logs if postgres is failing to start.

Since now the method returns `None` if postgres didn't start accepting connections after 60s, the change broke the standby-leader bootstrap code.

As the solution, we will assume that the clone was successful if the `follow()` method returned anything different from `False`.
2021-12-21 11:20:06 +01:00
Alexander KukushkinandGitHub 63586f0477 Add ctl.keyfile_password support (#2145)
It compliments restapi.keyfile_password added in the #1825
2021-12-21 11:19:39 +01:00
Alexander KukushkinandGitHub 4215565cb4 Rearrange tests (#2146)
- remove codacy steps: they removed legacy organizations and there seems to be no easy way of installing codacy app to the Zalando GH.
- Don't run behave on MacOS: recently worker became way to slow
- Disable behave for combination of kubernetes and python 2.7
- Remove python 3.5 (it will be removed by GH from workers in January) and add 3.10
- Run behave with 3.6 and 3.9 instead of 3.5 and 3.8
2021-12-21 09:36:22 +01:00
Alexander KukushkinandGitHub dc9ff4cb8a Release 2.1.2 (#2136)
* Implement missing unit-tests
* Bump version
* Update release notes
2021-12-03 15:49:57 +01:00
Alexander KukushkinandGitHub d7dc3c2d96 Handle missing timelines in history file when deciding to rewind (#2120)
When restore_command is configured Postgres is trying to fetch/apply all possible WAL segments and also fetch history files in order to select the correct timeline. It could result in a situation where the new history file will be missing some timelines.

Example:
- node1 demotes/crashes on timeline 1
- node2 promotes to timeline 2 and archives `00000002.history` and crashes
- node1 recovers as a replica, "replays" `00000002.history` and promotes to timeline 3

As a result, the `00000003.history` will not have the line with timeline 2, because it never replayed any WAL segment from it.
The `pg_rewind` tool is supposed to correctly handle such case when rewinding node2 from node1, but Patroni when deciding whether the rewind should happen was searching for the exact timeline in the history file from the new primary.

The solution is to assume that rewind is required if the current replica timeline is missing.

In addition to that this PR makes sure that the primary isn't running in recovery before starting the procedure of rewind check.

Close https://github.com/zalando/patroni/issues/2118 and https://github.com/zalando/patroni/issues/2124
2021-12-02 11:35:30 +01:00
Michael BanckandGitHub 90b3736fec Remove duplicate hosts from the etcd machine cache (#2127)
Close #2126
2021-12-02 11:35:11 +01:00
Alexander KukushkinandGitHub 63ee42a85c Clear event on the leader node when /status was updated (#2125)
Not doing so causing excessive HA loop runs with Zookeeper.
This moment wasn't fixed correctly in the #1875
2021-11-30 16:33:38 +01:00
Alexander KukushkinandGitHub d24051c31c Optimize case when we don't have permanent logical slots (#2121)
The unnecessary call of SlotsHandler.process_permanent_slots() results in one additional query to `pg_replication_slots` view every HA loop.
2021-11-30 14:20:55 +01:00
Alexander KukushkinandGitHub 31d7540cc5 Prefer members without nofailover when picking sync nodes (#2108)
Previously sync nodes were selected only based on replication lag and hence the node with `nofailover` tag had the same chances to become synchronous as any other node. That behavior was confusing and dangerous at the same time, because in case of failed primary the failover couldn't happen automatically.

Close https://github.com/zalando/patroni/issues/2089
2021-11-30 14:20:03 +01:00
Alexander KukushkinandGitHub 256a359a1e Fix a litle bug around psycopg 3.0 (#2123)
Cursor.execute now returns cursor itself, while in psycopg2 it was returning None
2021-11-19 16:29:39 +01:00
Alexander KukushkinandGitHub 17e523b175 Optimize checkpoint after promote (#2114)
1. Avoid doing CHECKPOINT if `pg_control` is already updated.
2. Explicitly call ensure_checkpoint_after_promote() right after the bootstrap finished successfully.
2021-11-19 14:33:24 +01:00
Alexander KukushkinandGitHub fce889cd04 Compatibility with psycopg 3.0 (#2088)
By default `psycopg2` is preferred. The `psycopg>=3.0` will be used only if `psycopg2` is not available or its version is too old.
2021-11-19 14:32:54 +01:00
Alexander KukushkinandGitHub edfe2a84e9 Fix a few issues with Patroni API (#2116)
1. The `client_address` tuple may have more than two elements in case of IPv6
2. Return `cluster_unlocked` only when the value is true and handle it respectively in the do_GET_metrics()
3. Return `cluster_unlocked` and `dcs_last_seen` even if Postgres isn't running/queries timing out

Close https://github.com/zalando/patroni/issues/2113
2021-11-12 15:02:53 +01:00
Alexander KukushkinandGitHub 00d125c512 Avoid unnecessary updates of the members ZNode. (#2115)
When deciding whether the ZNode should be updated we rely on the cached version of the cluster, which is updated only when members ZNodes are deleted/created or the `/status`, `/sync`, `/failover`, `/config`, or `/history` ZNodes are updated.

I.e. after the update of the current member ZNode succeeded the cache becomes stale and all further updates are always performed even if the value didn't change. In order to solve it, we introduce the new attribute in the Zookeeper class and will use it for memorizing the actual value and for later comparison.
2021-11-12 15:00:54 +01:00
Alexander KukushkinandGitHub fd1e0f1c1b BUGFIX: use_unix_socket_repl didn't work is some cases (#2103)
Specifically, if `postgresql.unix_socket_directories` is not set.
In this case Patroni is supposed to use only the port in the connection string, but the `get_replication_connection_cursor()` method defaulted to host='localhost'
2021-10-29 12:09:38 +02:00
Alwyn DavisandGitHub 14bb28c349 Allow setting ACLs for znodes in Zookeeper (#2086)
Add a configuration option (`set_acls`) for Zookeeper DCS so that Kazoo will apply a default ACL for each znode that it creates.  The intention is to improve security of the znodes when a single Zookeeper cluster is used as the DCS for multiple Patroni clusters.

Zookeeper [does not apply an ACL to child znodes](https://zookeeper.apache.org/doc/current/zookeeperProgrammers.html#sc_ZooKeeperAccessControl), so permissions can't be set at the `scope` level and then be inherited by other znodes that Patroni creates.

Kazoo instead [provides an option for configuring a default_acl](https://kazoo.readthedocs.io/en/latest/api/client.html#kazoo.client.KazooClient.__init__) that will be applied on node creation.

Example configuration in Patroni might then be:
```
zookeeper:
    set_acls:
        CN=principal1: [ALL]
        CN=principal2:
            - READ
```
2021-10-28 09:59:45 +02:00
Michael BanckandGitHub 95479526ef Fix typo (#2091) 2021-10-26 15:12:10 +02:00
Alexander KukushkinandGitHub 47ebda0d5d Fix a few issues in kubernetes.py (#2084)
1. Two `TypeError`-s raised from `ApiClient.request()` method
2. Use the _retry() wrapper function instead of callable object in the `_update_leader_with_retry()` when trying to workaround concurrent updates of the leader object.
2021-10-08 16:13:28 +02:00
Nicolas PAYARTandGitHub 64ae2bb885 Add compatibility with PG 14 in README (#2083)
It was just missing there
2021-10-08 15:51:55 +02:00
Alexander KukushkinandGitHub 250328b84b Use cached role as a fallback when postgres is slow (#2082)
In some extreme cases Postgres could be so slow that the normal monitoring query doesn't finish in a few seconds. It results in
the exception being raised from the `Postgresql._cluster_info_state_get()` method, which could lead to the situation that postgres isn't demoted on time.
In order to make it reliable we will catch the exception and use the cached state of postgres (`is_running()` and `role`) to determine whether postgres is running as a primary.

Close https://github.com/zalando/patroni/issues/2073
2021-10-07 16:08:21 +02:00
Alexander KukushkinandGitHub 89388c2e4b Handle DCS exceptions when demoting (#2081)
While doing demote due to failure to update leader lock it could happen that DCS goes completely down and the get_cluster() call raise the exception.
Not being properly handled it results in postgres remaining stopped until DCS recovers.
2021-10-07 16:08:10 +02:00
Michael BanckandGitHub e28557d2f0 Fix sphinx build. (#2080)
Sphinx' add_stylesheet() has been deprecated for a long time and got removed in recent versions of sphinx. If available, use add_css_file() instead.

Close #2079.
2021-10-07 16:07:41 +02:00
Farid ZarazvandandGitHub 34db0bba16 PostgreSQL v14 is supported since v2.1.0 (#2078) 2021-10-07 16:07:00 +02:00
Kostiantyn NemchenkoandGitHub 3616906434 Add sslcrldir connection parameter support (#2068)
This allows setting the `sslcrldir` connection parameter available since PostgreSQL 14.
2021-10-07 16:04:27 +02:00
Alexander KukushkinandGitHub d394b63c9f Release the leader lock when pg_controldata reports "shut down" (#2067)
Due to different reasons, it could happen that WAL archiving on the primary stuck or significantly delayed. If we try to do a switchover or shut it down, the shutdown will take forever and will not finish until the whole backlog of WALs is processed.
In the meantime, Patroni keeps updating the leader lock, which prevents other nodes from starting the leader race even if it is known that they received/applied all changes.

The `Database cluster state:` is changed to `"shut down"` after:
- all data is fsynced to disk and the latest checkpoint is written to WAL
- all streaming replicas confirmed that they received all changes (including the latest checkpoint)
- at the same time, the archiver process continues to do its job and the postmaster process is still running.

In order to solve this problem and make the switchover more reliable/fast in a case when `archive_command` is slow/failing, Patroni will remove the leader key immediately after `pg_controldata` started reporting PGDATA as `"shut down"` cleanly and it verified that there is at least one replica that received all changes. If there are no replicas that fulfill the condition the leader key isn't removed and the old behavior is retained, i.e. Patroni will keep updating it.
2021-10-05 10:55:35 +02:00
Alexander KukushkinandGitHub 1c2bf258d6 Allow switchover only to sync nodes when synchronous replication is on (#2076)
Close https://github.com/zalando/patroni/issues/2074
2021-10-04 16:23:45 +02:00
Alexander KukushkinandGitHub a431f50378 Check only sync nodes when assessing failover capabilities (#2065)
When synchronous_mode is enabled we should check only synchronous nodes in is_failover_possible().
2021-09-24 08:22:54 +02:00
Alexander KukushkinandGitHub fca724186e DCS.write_leader_optime() should update /status key (#2064)
This moment was forgotten in the failover logical slots implementation.
2021-09-24 08:22:20 +02:00
Michael BanckandGitHub 2f31e88bdc Add dcs_last_seen field to API (#2051)
This field notes the last time (as unix epoch) a cluster member has successfully communicated with the DCS. This is useful to identify and/or analyze network partitions.

Also, expose dcs_last_seen in the MemberStatus class and its from_api_response() method.
2021-09-22 10:01:35 +02:00
Jorge SolórzanoandGitHub 80c1127b70 Cast to int wal_keep_segments conversion to wal_keep_size (#2063)
Fixes #2062
2021-09-22 10:00:42 +02:00
Alexander KukushkinandGitHub 258e7e24f4 Ensure pg_replication_slot_advance() doesn't timeout (#2060)
The bigger gap between the slot flush LSN and the LSN we want to advance to becomes more time it takes for the call to finish.
Once started failing the "lag" will grow more or less infinitely, that have the following negative side-effects:
1. Size of pg_wal on the replica will grow
2. Since the hot_standby_feedback is forcefully enabled, the primary will stop cleaning up dead tuples
I.e., we are not only in danger of running out of disk space, but also increasing chances of transaction wraparound to happen.

In order to mitigate it, we want to set the `statement_timeout` to 0 before calling `pg_replication_slot_advance()`.

Since the call is happening from the main HA loop and could take more than `loop_wait`, the next heartbeat run could be delayed.
There is also a possibility that the call could take longer than `ttl` and the member key/session in DCS for a given replica expires, but, the slot LSN in DCS is updated by the primary every `loop_wait` seconds. Hence, we don't expect that the slot_advance() call will take significantly longer than the `loop_wait` and therefore chances of the member key/session to expire are very low.
2021-09-17 16:39:39 +02:00
Michael BanckandGitHub fae96b3148 Improve "I am" status messages (#2056) 2021-09-17 14:46:07 +02:00
Michael BanckandGitHub 8a9e649aa1 Add log before demoting (which can take some time) (#2057)
It can take some time for the demote to finish and it might not be obvious from looking at the logs what exactly is going on.
2021-09-17 14:45:32 +02:00
Alexander KukushkinandGitHub 7bd28250ca Skip temporary replication slots while doing slot management (#2055)
Starting from v10 `pg_basebackup` creates a temporary replication slot for WAL streaming and Patroni was trying to drop it because the slot name looks unknown. In order to fix it, we skip all temporary slots when querying `pg_stat_replication_slots` view.

Another option to solve the problem would be running `pg_basebackup` with `--slot=current_node_name` option, but unfortunately at the moment when `pg_basebackup` is executed, we don't yet know the major version (the `--slot` option was added in v9.6).

Ref: https://github.com/zalando/patroni/issues/2046#issuecomment-912521502
2021-09-17 14:44:54 +02:00
Alexander KukushkinandGitHub 21145d18d1 Delay the next attempt of recovery till next HA loop (#2054)
If Postgres crashed due to out of disk space (for example) and fails to start because of that Patroni is too eagerly trying to recover it and producing too many logs
2021-09-17 13:46:46 +02:00
59 changed files with 1079 additions and 631 deletions
+5 -2
View File
@@ -18,7 +18,10 @@ def install_requirements(what):
finally: finally:
sys.path = old_path sys.path = old_path
requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave'] requirements = ['mock>=2.0.0', 'flake8', 'pytest', 'pytest-cov'] if what == 'all' else ['behave']
requirements += ['psycopg2-binary', 'coverage'] requirements += ['coverage']
# try to split tests between psycopg2 and psycopg3
requirements += ['psycopg[binary]'] if sys.version_info >= (3, 6, 0) and\
(sys.platform != 'darwin' or what == 'etcd3') else ['psycopg2-binary']
for r in read('requirements.txt').split('\n'): for r in read('requirements.txt').split('\n'):
r = r.strip() r = r.strip()
if r != '': if r != '':
@@ -107,7 +110,7 @@ def install_etcd():
def install_postgres(): def install_postgres():
version = os.environ.get('PGVERSION', '12.1-1') version = os.environ.get('PGVERSION', '14.1-1')
platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform] platform = {'darwin': 'osx', 'win32': 'windows-x64', 'cygwin': 'windows-x64'}[sys.platform]
name = 'postgresql-{0}-{1}-binaries.zip'.format(version, platform) name = 'postgresql-{0}-{1}-binaries.zip'.format(version, platform)
get_file('http://get.enterprisedb.com/postgresql/' + name, name) get_file('http://get.enterprisedb.com/postgresql/' + name, name)
+1 -1
View File
@@ -27,7 +27,7 @@ def main():
version = versions.get(what) version = versions.get(what)
path = '/usr/lib/postgresql/{0}/bin:.'.format(version) path = '/usr/lib/postgresql/{0}/bin:.'.format(version)
unbuffer = ['timeout', '600', 'unbuffer'] unbuffer = ['timeout', '900', 'unbuffer']
args = ['--tags=-skip'] if what == 'etcd' else [] args = ['--tags=-skip'] if what == 'etcd' else []
else: else:
path = os.path.abspath(os.path.join('pgsql', 'bin')) path = os.path.abspath(os.path.join('pgsql', 'bin'))
+27 -53
View File
@@ -30,15 +30,6 @@ jobs:
run: python .github/workflows/run_tests.py run: python .github/workflows/run_tests.py
if: matrix.os != 'windows' if: matrix.os != 'windows'
- name: Set up Python 3.5
uses: actions/setup-python@v2
with:
python-version: 3.5
- name: Install dependencies
run: python .github/workflows/install_deps.py
- name: Run tests and flake8
run: python .github/workflows/run_tests.py
- name: Set up Python 3.6 - name: Set up Python 3.6
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
@@ -75,6 +66,15 @@ jobs:
- name: Run tests and flake8 - name: Run tests and flake8
run: python .github/workflows/run_tests.py run: python .github/workflows/run_tests.py
- name: Set up Python 3.10
uses: actions/setup-python@v2
with:
python-version: '3.10'
- name: Install dependencies
run: python .github/workflows/install_deps.py
- name: Run tests and flake8
run: python .github/workflows/run_tests.py
- name: Combine coverage - name: Combine coverage
run: python .github/workflows/run_tests.py combine run: python .github/workflows/run_tests.py combine
@@ -88,26 +88,31 @@ jobs:
GITHUB_TOKEN: ${{ secrets.github_token }} GITHUB_TOKEN: ${{ secrets.github_token }}
run: python -m coveralls --service=github run: python -m coveralls --service=github
- name: Run codacy-coverage-reporter
uses: codacy/codacy-coverage-reporter-action@master
env:
SECRETS_AVAILABLE: ${{ secrets.CODACY_PROJECT_TOKEN != '' }}
with:
project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
coverage-reports: coverage.xml
if: ${{ matrix.os == 'ubuntu' && env.SECRETS_AVAILABLE == 'true' }}
behave: behave:
runs-on: ${{ matrix.os }}-latest runs-on: ${{ matrix.os }}-latest
env: env:
DCS: ${{ matrix.dcs }} DCS: ${{ matrix.dcs }}
ETCDVERSION: 3.3.13 ETCDVERSION: 3.3.13
PGVERSION: 12.1-1 # for windows and macos
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu] os: [ubuntu]
python-version: [2.7, 3.5, 3.8] python-version: [2.7, 3.6, 3.9]
dcs: [etcd, etcd3, consul, exhibitor, kubernetes, raft] dcs: [etcd, etcd3, consul, exhibitor, kubernetes, raft]
exclude:
- dcs: kubernetes
python-version: 2.7
include:
- os: macos
python-version: 3.7
dcs: raft
- os: macos
python-version: 3.8
dcs: etcd
- os: macos
python-version: '3.10'
dcs: etcd3
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
@@ -117,45 +122,14 @@ jobs:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Add postgresql apt repo - name: Add postgresql apt repo
run: sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' run: sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
if: matrix.os == 'ubuntu'
- name: Install dependencies - name: Install dependencies
run: python .github/workflows/install_deps.py run: python .github/workflows/install_deps.py
- name: Run behave tests - name: Run behave tests
run: python .github/workflows/run_tests.py run: python .github/workflows/run_tests.py
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with: with:
python-version: 3.9 python-version: '3.10'
- name: Install coveralls
run: python -m pip install coveralls
- name: Upload Coverage
env:
COVERALLS_FLAG_NAME: behave-${{ matrix.os }}-${{ matrix.dcs }}-${{ matrix.python-version }}
COVERALLS_PARALLEL: 'true'
GITHUB_TOKEN: ${{ secrets.github_token }}
run: python -m coveralls --service=github
behavem:
runs-on: ${{ matrix.os }}-latest
env:
DCS: ${{ matrix.dcs }}
ETCDVERSION: 3.3.13
PGVERSION: 12.1-1 # for windows and macos
strategy:
fail-fast: false
matrix:
os: [macos] #, windows]
python-version: [3.7]
dcs: [etcd, etcd3, raft]
steps:
- uses: actions/checkout@v1
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: python .github/workflows/install_deps.py
- name: Run behave tests
run: python .github/workflows/run_tests.py
- name: Install coveralls - name: Install coveralls
run: python -m pip install coveralls run: python -m pip install coveralls
- name: Upload Coverage - name: Upload Coverage
@@ -167,7 +141,7 @@ jobs:
coveralls-finish: coveralls-finish:
name: Finalize coveralls.io name: Finalize coveralls.io
needs: [unit, behave, behavem] needs: [unit, behave]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
+8 -2
View File
@@ -12,7 +12,7 @@ Patroni is a template for you to create your own customized, high-availability s
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. 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 13. Currently supported PostgreSQL versions: 9.3 to 14.
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/zalando/patroni/blob/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation. **Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the `Kubernetes <https://github.com/zalando/patroni/blob/master/docs/kubernetes.rst>`__ chapter of the Patroni documentation.
@@ -61,7 +61,7 @@ To install requirements on a Mac, run the following:
brew install postgresql etcd haproxy libyaml python brew install postgresql etcd haproxy libyaml python
**Psycopg2** **Psycopg**
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages. Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it. Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
@@ -88,6 +88,12 @@ There are a few options available:
pip install psycopg2>=2.5.4 pip install psycopg2>=2.5.4
4. Use psycopg 3.0 instead of psycopg2
::
pip install psycopg[binary]
**General installation for pip** **General installation for pip**
Patroni can be installed with pip: Patroni can be installed with pip:
+4
View File
@@ -84,6 +84,7 @@ ZooKeeper
- **PATRONI\_ZOOKEEPER\_KEY**: (optional) File with the client key. - **PATRONI\_ZOOKEEPER\_KEY**: (optional) File with the client key.
- **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password. - **PATRONI\_ZOOKEEPER\_KEY\_PASSWORD**: (optional) The client key password.
- **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``. - **PATRONI\_ZOOKEEPER\_VERIFY**: (optional) Whether to verify certificate or not. Defaults to ``true``.
- **PATRONI\_ZOOKEEPER\_SET\_ACLS**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
.. note:: .. note::
It is required to install ``kazoo>=2.6.0`` to support SSL. It is required to install ``kazoo>=2.6.0`` to support SSL.
@@ -133,6 +134,7 @@ PostgreSQL
- **PATRONI\_REPLICATION\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate. - **PATRONI\_REPLICATION\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_REPLICATION\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate. - **PATRONI\_REPLICATION\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_REPLICATION\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list. - **PATRONI\_REPLICATION\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REPLICATION\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REPLICATION\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server - **PATRONI\_REPLICATION\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_REPLICATION\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding. - **PATRONI\_REPLICATION\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **PATRONI\_SUPERUSER\_USERNAME**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres. Also this user is used by pg_rewind. - **PATRONI\_SUPERUSER\_USERNAME**: name for the superuser, set during initialization (initdb) and later used by Patroni to connect to the postgres. Also this user is used by pg_rewind.
@@ -143,6 +145,7 @@ PostgreSQL
- **PATRONI\_SUPERUSER\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate. - **PATRONI\_SUPERUSER\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_SUPERUSER\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate. - **PATRONI\_SUPERUSER\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_SUPERUSER\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list. - **PATRONI\_SUPERUSER\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_SUPERUSER\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server - **PATRONI\_SUPERUSER\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding. - **PATRONI\_SUPERUSER\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **PATRONI\_REWIND\_USERNAME**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted. - **PATRONI\_REWIND\_USERNAME**: name for the user for ``pg_rewind``; the user will be created during initialization of postgres 11+ and all necessary `permissions <https://www.postgresql.org/docs/11/app-pgrewind.html#id-1.9.5.8.8>`__ will be granted.
@@ -153,6 +156,7 @@ PostgreSQL
- **PATRONI\_REWIND\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate. - **PATRONI\_REWIND\_SSLCERT**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **PATRONI\_REWIND\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate. - **PATRONI\_REWIND\_SSLROOTCERT**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **PATRONI\_REWIND\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list. - **PATRONI\_REWIND\_SSLCRL**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REWIND\_SSLCRLDIR**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **PATRONI\_REWIND\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server - **PATRONI\_REWIND\_GSSENCMODE**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **PATRONI\_REWIND\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding. - **PATRONI\_REWIND\_CHANNEL\_BINDING**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
+7 -1
View File
@@ -35,7 +35,7 @@ To install requirements on a Mac, run the following:
.. _psycopg2_install_options: .. _psycopg2_install_options:
**Psycopg2** **Psycopg**
Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages. Starting from `psycopg2-2.8 <http://initd.org/psycopg/articles/2019/04/04/psycopg-28-released/>`__ the binary version of psycopg2 will no longer be installed by default. Installing it from the source code requires C compiler and postgres+python dev packages.
Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it. Since in the python world it is not possible to specify dependency as ``psycopg2 OR psycopg2-binary`` you will have to decide how to install it.
@@ -62,6 +62,12 @@ There are a few options available:
pip install psycopg2>=2.5.4 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** **General installation for pip**
Patroni can be installed with pip: Patroni can be installed with pip:
+6 -1
View File
@@ -182,6 +182,7 @@ ZooKeeper
- **key**: (optional) File with the client key. - **key**: (optional) File with the client key.
- **key_password**: (optional) The client key password. - **key_password**: (optional) The client key password.
- **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``. - **verify**: (optional) Whether to verify certificate or not. Defaults to ``true``.
- **set_acls**: (optional) If set, configure Kazoo to apply a default ACL to each ZNode that it creates. ACLs will assume 'x509' schema and should be specified as a dictionary with the principal as the key and one or more permissions as a list in the value. Permissions may be one of ``CREATE``, ``READ``, ``WRITE``, ``DELETE`` or ``ADMIN``. For example, ``set_acls: {CN=principal1: [CREATE, READ], CN=principal2: [ALL]}``.
.. note:: .. note::
It is required to install ``kazoo>=2.6.0`` to support SSL. It is required to install ``kazoo>=2.6.0`` to support SSL.
@@ -256,6 +257,7 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate. - **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate. - **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list. - **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server - **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding. - **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **replication**: - **replication**:
@@ -267,6 +269,7 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate. - **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate. - **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list. - **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server - **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding. - **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **rewind**: - **rewind**:
@@ -278,6 +281,7 @@ PostgreSQL
- **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate. - **sslcert**: (optional) maps to the `sslcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCERT>`__ connection parameter, which specifies the location of the client certificate.
- **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate. - **sslrootcert**: (optional) maps to the `sslrootcert <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLROOTCERT>`__ connection parameter, which specifies the location of a file containing one ore more certificate authorities (CA) certificates that the client will use to verify a server's certificate.
- **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list. - **sslcrl**: (optional) maps to the `sslcrl <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRL>`__ connection parameter, which specifies the location of a file containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **sslcrldir**: (optional) maps to the `sslcrldir <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLCRLDIR>`__ connection parameter, which specifies the location of a directory with files containing a certificate revocation list. A client will reject connecting to any server that has a certificate present in this list.
- **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server - **gssencmode**: (optional) maps to the `gssencmode <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE>`__ connection parameter, which determines whether or with what priority a secure GSS TCP/IP connection will be negotiated with the server
- **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding. - **channel_binding**: (optional) maps to the `channel_binding <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING>`__ connection parameter, which controls the client's use of channel binding.
- **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.) - **callbacks**: callback scripts to run on certain actions. Patroni will pass the action, role and cluster name. (See scripts/aws.py as an example of how to write them.)
@@ -325,7 +329,7 @@ REST API
- **password**: Basic-auth password to protect unsafe REST API endpoints. - **password**: Basic-auth password to protect unsafe REST API endpoints.
- **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL. - **certfile**: (optional): Specifies the file with the certificate in the PEM format. If the certfile is not specified or is left empty, the API server will work without SSL.
- **keyfile**: (optional): Specifies the file with the secret key in the PEM format. - **keyfile**: (optional): Specifies the file with the secret key in the PEM format.
- **keyfile_password**: (optional): Specifies a password for decrypting the keyfile. - **keyfile\_password**: (optional): Specifies a password for decrypting the keyfile.
- **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs. - **cafile**: (optional): Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **ciphers**: (optional): Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1") - **ciphers**: (optional): Specifies the permitted cipher suites (e.g. "ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES128-GCM-SHA256:!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1")
- **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests. - **verify\_client**: (optional): ``none`` (default), ``optional`` or ``required``. When ``none`` REST API will not check client certificates. When ``required`` client certificates are required for all REST API calls. When ``optional`` client certificates are required for all unsafe REST API endpoints. When ``required`` is used, then client authentication succeeds, if the certificate signature verification succeeds. For ``optional`` the client cert will only be checked for ``PUT``, ``POST``, ``PATCH``, and ``DELETE`` requests.
@@ -363,6 +367,7 @@ CTL
- **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 patronictl will use the value provided for REST API "cafile" parameter.
- **certfile**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter. - **certfile**: Specifies the file with the client certificate in the PEM format. If not provided patronictl will use the value provided for REST API "certfile" parameter.
- **keyfile**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter. - **keyfile**: Specifies the file with the client secret key in the PEM format. If not provided patronictl will use the value provided for REST API "keyfile" parameter.
- **keyfile\_password**: Specifies a password for decrypting the keyfile. If not provided patronictl will use the value provided for REST API "keyfile\_password" parameter.
Watchdog Watchdog
-------- --------
+4 -1
View File
@@ -194,4 +194,7 @@ intersphinx_mapping = {'https://docs.python.org/': None}
# A possibility to have an own stylesheet, to add new rules or override existing ones # A possibility to have an own stylesheet, to add new rules or override existing ones
# For the latter case, the CSS specificity of the rules should be higher than the default ones # For the latter case, the CSS specificity of the rules should be higher than the default ones
def setup(app): def setup(app):
app.add_stylesheet("custom.css") if hasattr(app, 'add_css_file'):
app.add_css_file('custom.css')
else:
app.add_stylesheet('custom.css')
+1 -1
View File
@@ -10,7 +10,7 @@ Patroni is a template for you to create your own customized, high-availability s
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>`__. 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 13. Currently supported PostgreSQL versions: 9.3 to 14.
**Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the :ref:`Kubernetes <kubernetes>` chapter of the Patroni documentation. **Note to Kubernetes users**: Patroni can run natively on top of Kubernetes. Take a look at the :ref:`Kubernetes <kubernetes>` chapter of the Patroni documentation.
+170
View File
@@ -3,6 +3,176 @@
Release notes Release notes
============= =============
Version 2.1.3
-------------
**New features**
- Added support for encrypted TLS keys for ``patronictl`` (Alexander Kukushkin)
It could be configured via ``ctl.keyfile_password`` or the ``PATRONI_CTL_KEYFILE_PASSWORD`` environment variable.
- Added more metrics to the /metrics endpoint (Alexandre Pereira)
Specifically, ``patroni_pending_restart`` and ``patroni_is_paused``.
- Make it possible to specify multiple hosts in the standby cluster configuration (Michael Banck)
If the standby cluster is replicating from the Patroni cluster it might be nice to rely on client-side failover which is available in ``libpq`` since PostgreSQL v10. That is, the ``primary_conninfo`` on the standby leader and ``pg_rewind`` setting ``target_session_attrs=read-write`` in the connection string. The ``pgpass`` file will be generated with multiple lines (one line per host), and instead of calling ``CHECKPOINT`` on the primary cluster nodes the standby cluster will wait for ``pg_control`` to be updated.
**Stability improvements**
- Compatibility with legacy ``psycopg2`` (Alexander)
For example, the ``psycopg2`` installed from Ubuntu 18.04 packages doesn't have the ``UndefinedFile`` exception yet.
- Restart ``etcd3`` watcher if all Etcd nodes don't respond (Alexander)
If the watcher is alive the ``get_cluster()`` method continues returning stale information even if all Etcd nodes are failing.
- Don't remove the leader lock in the standby cluster while paused (Alexander)
Previously the lock was maintained only by the node that was running as a primary and not a standby leader.
**Bugfixes**
- Fixed bug in the standby-leader bootstrap (Alexander)
Patroni was considering bootstrap as failed if Postgres didn't start accepting connections after 60 seconds. The bug was introduced in the 2.1.2 release.
- Fixed bug with failover to a cascading standby (Alexander)
When figuring out which slots should be created on cascading standby we forgot to take into account that the leader might be absent.
- Fixed small issues in Postgres config validator (Alexander)
Integer parameters introduced in PostgreSQL v14 were failing to validate because min and max values were quoted in the validator.py
- Use replication credentials when checking leader status (Alexander)
It could be that the ``remove_data_directory_on_diverged_timelines`` is set, but there is no ``rewind_credentials`` defined and superuser access between nodes is not allowed.
- Fixed "port in use" error on REST API certificate replacement (Ants Aasma)
When switching certificates there was a race condition with a concurrent API request. If there is one active during the replacement period then the replacement will error out with a port in use error and Patroni gets stuck in a state without an active API server.
- Fixed a bug in cluster bootstrap if passwords contain ``%`` characters (Bastien Wirtz)
The bootstrap method executes the ``DO`` block, with all parameters properly quoted, but the ``cursor.execute()`` method didn't like an empty list with parameters passed.
- Fixed the "AttributeError: no attribute 'leader'" exception (Hrvoje Milković)
It could happen if the synchronous mode is enabled and the DCS content was wiped out.
- Fix bug in divergence timeline check (Alexander)
Patroni was falsely assuming that timelines have diverged. For pg_rewind it didn't create any problem, but if pg_rewind is not allowed and the ``remove_data_directory_on_diverged_timelines`` is set, it resulted in reinitializing the former leader.
Version 2.1.2
-------------
**New features**
- Compatibility with ``psycopg>=3.0`` (Alexander Kukushkin)
By default ``psycopg2`` is preferred. `psycopg>=3.0` will be used only if ``psycopg2`` is not available or its version is too old.
- Add ``dcs_last_seen`` field to the REST API (Michael Banck)
This field notes the last time (as unix epoch) a cluster member has successfully communicated with the DCS. This is useful to identify and/or analyze network partitions.
- Release the leader lock when ``pg_controldata`` reports "shut down" (Alexander)
To solve the problem of slow switchover/shutdown in case ``archive_command`` is slow/failing, Patroni will remove the leader key immediately after ``pg_controldata`` started reporting PGDATA as ``shut down`` cleanly and it verified that there is at least one replica that received all changes. If there are no replicas that fulfill this condition the leader key is not removed and the old behavior is retained, i.e. Patroni will keep updating the lock.
- Add ``sslcrldir`` connection parameter support (Kostiantyn Nemchenko)
The new connection parameter was introduced in the PostgreSQL v14.
- Allow setting ACLs for ZNodes in Zookeeper (Alwyn Davis)
Introduce a new configuration option ``zookeeper.set_acls`` so that Kazoo will apply a default ACL for each ZNode that it creates.
**Stability improvements**
- Delay the next attempt of recovery till next HA loop (Alexander)
If Postgres crashed due to out of disk space (for example) and fails to start because of that Patroni is too eagerly trying to recover it flooding logs.
- Add log before demoting, which can take some time (Michael)
It can take some time for the demote to finish and it might not be obvious from looking at the logs what exactly is going on.
- Improve "I am" status messages (Michael)
``no action. I am a secondary ({0})`` vs ``no action. I am ({0}), a secondary``
- Cast to int ``wal_keep_segments`` when converting to ``wal_keep_size`` (Jorge Solórzano)
It is possible to specify ``wal_keep_segments`` as a string in the global :ref:`dynamic configuration <dynamic_configuration>` and due to Python being a dynamically typed language the string was simply multiplied. Example: ``wal_keep_segments: "100"`` was converted to ``100100100100100100100100100100100100100100100100MB``.
- Allow switchover only to sync nodes when synchronous replication is enabled (Alexander)
In addition to that do the leader race only against known synchronous nodes.
- Use cached role as a fallback when Postgres is slow (Alexander)
In some extreme cases Postgres could be so slow that the normal monitoring query does not finish in a few seconds. The ``statement_timeout`` exception not being properly handled could lead to the situation where Postgres was not demoted on time when the leader key expired or the update failed. In case of such exception Patroni will use the cached ``role`` to determine whether Postgres is running as a primary.
- Avoid unnecessary updates of the member ZNode (Alexander)
If no values have changed in the members data, the update should not happen.
- Optimize checkpoint after promote (Alexander)
Avoid doing ``CHECKPOINT`` if the latest timeline is already stored in ``pg_control``. It helps to avoid unnecessary ``CHECKPOINT`` right after initializing the new cluster with ``initdb``.
- Prefer members without ``nofailover`` when picking sync nodes (Alexander)
Previously sync nodes were selected only based on the replication lag, hence the node with ``nofailover`` tag had the same chances to become synchronous as any other node. That behavior was confusing and dangerous at the same time because in case of a failed primary the failover could not happen automatically.
- Remove duplicate hosts from the etcd machine cache (Michael)
Advertised client URLs in the etcd cluster could be misconfigured. Removing duplicates in Patroni in this case is a low-hanging fruit.
**Bugfixes**
- Skip temporary replication slots while doing slot management (Alexander)
Starting from v10 ``pg_basebackup`` creates a temporary replication slot for WAL streaming and Patroni was trying to drop it because the slot name looks unknown. In order to fix it, we skip all temporary slots when querying ``pg_stat_replication_slots`` view.
- Ensure ``pg_replication_slot_advance()`` doesn't timeout (Alexander)
Patroni was using the default ``statement_timeout`` in this case and once the call failed there are very high chances that it will never recover, resulting in increased size of ``pg_wal`` and ``pg_catalog`` bloat.
- The ``/status`` wasn't updated on demote (Alexander)
After demoting PostgreSQL the old leader updates the last LSN in DCS. Starting from ``2.1.0`` the new ``/status`` key was introduced, but the optime was still written to the ``/optime/leader``.
- Handle DCS exceptions when demoting (Alexander)
While demoting the master due to failure to update the leader lock it could happen that DCS goes completely down and the ``get_cluster()`` call raises an exception. Not being handled properly it results in Postgres remaining stopped until DCS recovers.
- The ``use_unix_socket_repl`` didn't work is some cases (Alexander)
Specifically, if ``postgresql.unix_socket_directories`` is not set. In this case Patroni is supposed to use the default value from ``libpq``.
- Fix a few issues with Patroni REST API (Alexander)
The ``clusters_unlocked`` sometimes could be not defined, what resulted in exceptions in the ``GET /metrics`` endpoint. In addition to that the error handling method was assuming that the ``connect_address`` tuple always has two elements, while in fact there could be more in case of IPv6.
- Wait for newly promoted node to finish recovery before deciding to rewind (Alexander)
It could take some time before the actual promote happens and the new timeline is created. Without waiting replicas could come to the conclusion that rewind isn't required.
- Handle missing timelines in a history file when deciding to rewind (Alexander)
If the current replica timeline is missing in the history file on the primary the replica was falsely assuming that rewind isn't required.
Version 2.1.1 Version 2.1.1
------------- -------------
+8 -7
View File
@@ -1,7 +1,6 @@
import abc import abc
import datetime import datetime
import os import os
import psycopg2
import json import json
import shutil import shutil
import signal import signal
@@ -13,6 +12,8 @@ import threading
import time import time
import yaml import yaml
import patroni.psycopg as psycopg
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
@@ -205,16 +206,16 @@ class PatroniController(AbstractController):
user = config['postgresql'].get('authentication', config['postgresql']).get('superuser', {}) user = config['postgresql'].get('authentication', config['postgresql']).get('superuser', {})
self._connkwargs = {k: user[n] for n, k in [('username', 'user'), ('password', 'password')] if n in user} self._connkwargs = {k: user[n] for n, k in [('username', 'user'), ('password', 'password')] if n in user}
self._connkwargs.update({'host': host, 'port': self.__PORT, 'database': 'postgres'}) self._connkwargs.update({'host': host, 'port': self.__PORT, 'dbname': 'postgres'})
self._replication = config['postgresql'].get('authentication', config['postgresql']).get('replication', {}) self._replication = config['postgresql'].get('authentication', config['postgresql']).get('replication', {})
self._replication.update({'host': host, 'port': self.__PORT, 'database': 'postgres'}) self._replication.update({'host': host, 'port': self.__PORT, 'dbname': 'postgres'})
return patroni_config_path return patroni_config_path
def _connection(self): def _connection(self):
if not self._conn or self._conn.closed != 0: if not self._conn or self._conn.closed != 0:
self._conn = psycopg2.connect(**self._connkwargs) self._conn = psycopg.connect(**self._connkwargs)
self._conn.autocommit = True self._conn.autocommit = True
return self._conn return self._conn
@@ -228,7 +229,7 @@ class PatroniController(AbstractController):
cursor = self._cursor() cursor = self._cursor()
cursor.execute(query) cursor.execute(query)
return cursor return cursor
except psycopg2.Error: except psycopg.Error:
if not fail_ok: if not fail_ok:
raise raise
@@ -268,7 +269,7 @@ class PatroniController(AbstractController):
@property @property
def backup_source(self): def backup_source(self):
return 'postgres://{username}:{password}@{host}:{port}/{database}'.format(**self._replication) return 'postgres://{username}:{password}@{host}:{port}/{dbname}'.format(**self._replication)
def backup(self, dest=os.path.join('data', 'basebackup')): def backup(self, dest=os.path.join('data', 'basebackup')):
subprocess.call(PatroniPoolController.BACKUP_SCRIPT + ['--walmethod=none', subprocess.call(PatroniPoolController.BACKUP_SCRIPT + ['--walmethod=none',
@@ -659,7 +660,7 @@ class PatroniPoolController(object):
def output_dir(self): def output_dir(self):
return self._output_dir return self._output_dir
def start(self, name, max_wait_limit=20, custom_config=None): def start(self, name, max_wait_limit=40, custom_config=None):
if name not in self._processes: if name not in self._processes:
self._processes[name] = PatroniController(self._context, name, self.patroni_path, self._processes[name] = PatroniController(self._context, name, self.patroni_path,
self._output_dir, custom_config) self._output_dir, custom_config)
+6 -5
View File
@@ -51,11 +51,11 @@ Scenario: check the scheduled restart
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}} Given I issue a PATCH request to http://127.0.0.1:8008/config with {"postgresql": {"parameters": {"superuser_reserved_connections": "6"}}}
Then I receive a response code 200 Then I receive a response code 200
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 5 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 3 seconds with {"role": "replica"} Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"role": "replica"}
Then I receive a response code 202 Then I receive a response code 202
And I sleep for 4 seconds And I sleep for 8 seconds
And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds And Response on GET http://127.0.0.1:8008/patroni contains pending_restart after 10 seconds
Given I issue a scheduled restart at http://127.0.0.1:8008 in 3 seconds with {"restart_pending": "True"} Given I issue a scheduled restart at http://127.0.0.1:8008 in 5 seconds with {"restart_pending": "True"}
Then I receive a response code 202 Then I receive a response code 202
And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds And Response on GET http://127.0.0.1:8008/patroni does not contain pending_restart after 10 seconds
And postgres0 role is the primary after 10 seconds And postgres0 role is the primary after 10 seconds
@@ -71,6 +71,7 @@ Scenario: check API requests for the primary-replica pair in the pause mode
When I run patronictl.py restart batman postgres1 --force When I run patronictl.py restart batman postgres1 --force
Then I receive a response returncode 0 Then I receive a response returncode 0
Then replication works from postgres0 to postgres1 after 20 seconds Then replication works from postgres0 to postgres1 after 20 seconds
And I sleep for 2 seconds
When I issue a GET request to http://127.0.0.1:8009/replica When I issue a GET request to http://127.0.0.1:8009/replica
Then I receive a response code 200 Then I receive a response code 200
And I receive a response state running And I receive a response state running
@@ -103,12 +104,12 @@ Scenario: check the switchover via the API in the pause mode
Then I receive a response code 503 Then I receive a response code 503
Scenario: check the scheduled switchover Scenario: check the scheduled switchover
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds Given I issue a scheduled switchover from postgres1 to postgres0 in 10 seconds
Then I receive a response returncode 1 Then I receive a response returncode 1
And I receive a response output "Can't schedule switchover in the paused state" And I receive a response output "Can't schedule switchover in the paused state"
When I run patronictl.py resume batman When I run patronictl.py resume batman
Then I receive a response returncode 0 Then I receive a response returncode 0
Given I issue a scheduled switchover from postgres1 to postgres0 in 3 seconds Given I issue a scheduled switchover from postgres1 to postgres0 in 5 seconds
Then I receive a response returncode 0 Then I receive a response returncode 0
And postgres0 is a leader after 20 seconds And postgres0 is a leader after 20 seconds
And postgres0 role is the primary after 10 seconds And postgres0 role is the primary after 10 seconds
+1 -1
View File
@@ -1,4 +1,4 @@
import psycopg2 as pg import patroni.psycopg as pg
from behave import step, then from behave import step, then
from time import sleep, time from time import sleep, time
+2
View File
@@ -76,6 +76,8 @@ def do_request(context, request_method, url, data):
data = data and json.loads(data) data = data and json.loads(data)
try: try:
r = request_executor.request(request_method, url, data) r = request_executor.request(request_method, url, data)
if request_method == 'PATCH' and r.status == 409:
r = request_executor.request(request_method, url, data)
except Exception: except Exception:
context.status_code = context.response = None context.status_code = context.response = None
else: else:
+4 -4
View File
@@ -1,7 +1,7 @@
import time import time
import psycopg2
from behave import step, then from behave import step, then
import patroni.psycopg as pg
@step('I create a logical replication slot {slot_name} on {pg_name:w} with the {plugin:w} plugin') @step('I create a logical replication slot {slot_name} on {pg_name:w} with the {plugin:w} plugin')
@@ -10,7 +10,7 @@ def create_logical_replication_slot(context, slot_name, pg_name, plugin):
output = context.pctl.query(pg_name, ("SELECT pg_create_logical_replication_slot('{0}', '{1}')," output = context.pctl.query(pg_name, ("SELECT pg_create_logical_replication_slot('{0}', '{1}'),"
" current_database()").format(slot_name, plugin)) " current_database()").format(slot_name, plugin))
print(output.fetchone()) print(output.fetchone())
except psycopg2.Error as e: except pg.Error as e:
print(e) print(e)
assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin) assert False, "Error creating slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
@@ -24,7 +24,7 @@ def has_logical_replication_slot(context, pg_name, slot_name, plugin):
assert row[0] == "logical", "Found replication slot named {0} but wasn't a logical slot".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 " assert row[1] == plugin, ("Found replication slot named {0} but was using plugin "
"{1} rather than {2}").format(slot_name, row[1], plugin) "{1} rather than {2}").format(slot_name, row[1], plugin)
except psycopg2.Error: except pg.Error:
assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin) assert False, "Error looking for slot {0} on {1} with plugin {2}".format(slot_name, pg_name, plugin)
@@ -34,7 +34,7 @@ def does_not_have_logical_replication_slot(context, pg_name, slot_name):
row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots" row = context.pctl.query(pg_name, ("SELECT 1 FROM pg_replication_slots"
" WHERE slot_name = '{0}'").format(slot_name)).fetchone() " WHERE slot_name = '{0}'").format(slot_name)).fetchone()
assert not row, "Found unexpected replication slot named {0}".format(slot_name) assert not row, "Found unexpected replication slot named {0}".format(slot_name)
except psycopg2.Error: except pg.Error:
assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name) assert False, "Error looking for slot {0} on {1}".format(slot_name, pg_name)
+1 -1
View File
@@ -57,7 +57,7 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
@step('{pg_name1:w} is replicating from {pg_name2:w} after {timeout:d} seconds') @step('{pg_name1:w} is replicating from {pg_name2:w} after {timeout:d} seconds')
def check_replication_status(context, pg_name1, pg_name2, timeout): def check_replication_status(context, pg_name1, pg_name2, timeout):
bound_time = time.time() + timeout bound_time = time.time() + timeout * context.timeout_multiplier
while time.time() < bound_time: while time.time() < bound_time:
cur = context.pctl.query( cur = context.pctl.query(
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python #!/usr/bin/env python
from patroni import main from patroni.__main__ import main
if __name__ == '__main__': if __name__ == '__main__':
+21 -186
View File
@@ -1,142 +1,8 @@
import logging
import os
import signal
import sys import sys
import time
from .daemon import AbstractPatroniDaemon, abstract_main
from .version import __version__
logger = logging.getLogger(__name__)
PATRONI_ENV_PREFIX = 'PATRONI_' PATRONI_ENV_PREFIX = 'PATRONI_'
KUBERNETES_ENV_PREFIX = 'KUBERNETES_' KUBERNETES_ENV_PREFIX = 'KUBERNETES_'
MIN_PSYCOPG2 = (2, 5, 4)
class Patroni(AbstractPatroniDaemon):
def __init__(self, config):
from patroni.api import RestApiServer
from patroni.dcs import get_dcs
from patroni.ha import Ha
from patroni.postgresql import Postgresql
from patroni.request import PatroniRequest
from patroni.watchdog import Watchdog
super(Patroni, self).__init__(config)
self.version = __version__
self.dcs = get_dcs(self.config)
self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql'])
self.api = RestApiServer(self, self.config['restapi'])
self.request = PatroniRequest(self.config, True)
self.ha = Ha(self)
self.tags = self.get_tags()
self.next_run = time.time()
self.scheduled_restart = {}
def load_dynamic_configuration(self):
from patroni.exceptions import DCSError
while True:
try:
cluster = self.dcs.get_cluster()
if cluster and cluster.config and cluster.config.data:
if self.config.set_dynamic_configuration(cluster.config):
self.dcs.reload_config(self.config)
self.watchdog.reload_config(self.config)
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
self.dcs.reload_config(self.config)
break
except DCSError:
logger.warning('Can not get cluster from dcs')
time.sleep(5)
def get_tags(self):
return {tag: value for tag, value in self.config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
@property
def nofailover(self):
return bool(self.tags.get('nofailover', False))
@property
def nosync(self):
return bool(self.tags.get('nosync', False))
def reload_config(self, sighup=False, local=False):
try:
super(Patroni, self).reload_config(sighup, local)
if local:
self.tags = self.get_tags()
self.request.reload_config(self.config)
if local or sighup and self.api.reload_local_certificate():
self.api.reload_config(self.config['restapi'])
self.watchdog.reload_config(self.config)
self.postgresql.reload_config(self.config['postgresql'], sighup)
self.dcs.reload_config(self.config)
except Exception:
logger.exception('Failed to reload config_file=%s', self.config.config_file)
@property
def replicatefrom(self):
return self.tags.get('replicatefrom')
@property
def noloadbalance(self):
return bool(self.tags.get('noloadbalance', False))
def schedule_next_run(self):
self.next_run += self.dcs.loop_wait
current_time = time.time()
nap_time = self.next_run - current_time
if nap_time <= 0:
self.next_run = current_time
# Release the GIL so we don't starve anyone waiting on async_executor lock
time.sleep(0.001)
# Warn user that Patroni is not keeping up
logger.warning("Loop time exceeded, rescheduling immediately.")
elif self.ha.watch(nap_time):
self.next_run = time.time()
def run(self):
self.api.start()
self.next_run = time.time()
super(Patroni, self).run()
def _run_cycle(self):
logger.info(self.ha.run_cycle())
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
self.reload_config()
if self.postgresql.role != 'uninitialized':
self.config.save_cache()
self.schedule_next_run()
def _shutdown(self):
try:
self.api.shutdown()
except Exception:
logger.exception('Exception during RestApi.shutdown')
try:
self.ha.shutdown()
except Exception:
logger.exception('Exception during Ha.shutdown')
def patroni_main():
from multiprocessing import freeze_support
from patroni.validator import schema
freeze_support()
abstract_main(Patroni, schema)
def fatal(string, *args): def fatal(string, *args):
@@ -144,63 +10,32 @@ def fatal(string, *args):
sys.exit(1) sys.exit(1)
def check_psycopg2(): def parse_version(version):
min_psycopg2 = (2, 5, 4) def _parse_version(version):
min_psycopg2_str = '.'.join(map(str, min_psycopg2))
def parse_version(version):
for e in version.split('.'): for e in version.split('.'):
try: try:
yield int(e) yield int(e)
except ValueError: except ValueError:
break break
return tuple(_parse_version(version.split(' ')[0]))
# We pass MIN_PSYCOPG2 and parse_version as arguments to simplify usage of check_psycopg from the setup.py
def check_psycopg(_min_psycopg2=MIN_PSYCOPG2, _parse_version=parse_version):
min_psycopg2_str = '.'.join(map(str, _min_psycopg2))
try: try:
import psycopg2 from psycopg2 import __version__
version_str = psycopg2.__version__.split(' ')[0] if _parse_version(__version__) >= _min_psycopg2:
version = tuple(parse_version(version_str)) return
if version < min_psycopg2: version_str = __version__.split(' ')[0]
fatal('Patroni requires psycopg2>={0}, but only {1} is available', min_psycopg2_str, version_str)
except ImportError: except ImportError:
fatal('Patroni requires psycopg2>={0} or psycopg2-binary', min_psycopg2_str) version_str = None
try:
def main(): from psycopg import __version__
if os.getpid() != 1: except ImportError:
check_psycopg2() error = 'Patroni requires psycopg2>={0}, psycopg2-binary, or psycopg>=3.0'.format(min_psycopg2_str)
return patroni_main() if version_str:
error += ', but only psycopg2=={0} is available'.format(version_str)
# Patroni started with PID=1, it looks like we are in the container fatal(error)
pid = 0
# Looks like we are in a docker, so we will act like init
def sigchld_handler(signo, stack_frame):
try:
while True:
ret = os.waitpid(-1, os.WNOHANG)
if ret == (0, 0):
break
elif ret[0] != pid:
logger.info('Reaped pid=%s, exit status=%s', *ret)
except OSError:
pass
def passtochild(signo, stack_frame):
if pid:
os.kill(pid, signo)
if os.name != 'nt':
signal.signal(signal.SIGCHLD, sigchld_handler)
signal.signal(signal.SIGHUP, passtochild)
signal.signal(signal.SIGQUIT, passtochild)
signal.signal(signal.SIGUSR1, passtochild)
signal.signal(signal.SIGUSR2, passtochild)
signal.signal(signal.SIGINT, passtochild)
signal.signal(signal.SIGABRT, passtochild)
signal.signal(signal.SIGTERM, passtochild)
import multiprocessing
patroni = multiprocessing.Process(target=patroni_main)
patroni.start()
pid = patroni.pid
patroni.join()
+178 -1
View File
@@ -1,4 +1,181 @@
from patroni import main import logging
import os
import signal
import time
from .daemon import AbstractPatroniDaemon, abstract_main
logger = logging.getLogger(__name__)
class Patroni(AbstractPatroniDaemon):
def __init__(self, config):
from .api import RestApiServer
from .dcs import get_dcs
from .ha import Ha
from .postgresql import Postgresql
from .request import PatroniRequest
from .version import __version__
from .watchdog import Watchdog
super(Patroni, self).__init__(config)
self.version = __version__
self.dcs = get_dcs(self.config)
self.watchdog = Watchdog(self.config)
self.load_dynamic_configuration()
self.postgresql = Postgresql(self.config['postgresql'])
self.api = RestApiServer(self, self.config['restapi'])
self.request = PatroniRequest(self.config, True)
self.ha = Ha(self)
self.tags = self.get_tags()
self.next_run = time.time()
self.scheduled_restart = {}
def load_dynamic_configuration(self):
from patroni.exceptions import DCSError
while True:
try:
cluster = self.dcs.get_cluster()
if cluster and cluster.config and cluster.config.data:
if self.config.set_dynamic_configuration(cluster.config):
self.dcs.reload_config(self.config)
self.watchdog.reload_config(self.config)
elif not self.config.dynamic_configuration and 'bootstrap' in self.config:
if self.config.set_dynamic_configuration(self.config['bootstrap']['dcs']):
self.dcs.reload_config(self.config)
break
except DCSError:
logger.warning('Can not get cluster from dcs')
time.sleep(5)
def get_tags(self):
return {tag: value for tag, value in self.config.get('tags', {}).items()
if tag not in ('clonefrom', 'nofailover', 'noloadbalance', 'nosync') or value}
@property
def nofailover(self):
return bool(self.tags.get('nofailover', False))
@property
def nosync(self):
return bool(self.tags.get('nosync', False))
def reload_config(self, sighup=False, local=False):
try:
super(Patroni, self).reload_config(sighup, local)
if local:
self.tags = self.get_tags()
self.request.reload_config(self.config)
if local or sighup and self.api.reload_local_certificate():
self.api.reload_config(self.config['restapi'])
self.watchdog.reload_config(self.config)
self.postgresql.reload_config(self.config['postgresql'], sighup)
self.dcs.reload_config(self.config)
except Exception:
logger.exception('Failed to reload config_file=%s', self.config.config_file)
@property
def replicatefrom(self):
return self.tags.get('replicatefrom')
@property
def noloadbalance(self):
return bool(self.tags.get('noloadbalance', False))
def schedule_next_run(self):
self.next_run += self.dcs.loop_wait
current_time = time.time()
nap_time = self.next_run - current_time
if nap_time <= 0:
self.next_run = current_time
# Release the GIL so we don't starve anyone waiting on async_executor lock
time.sleep(0.001)
# Warn user that Patroni is not keeping up
logger.warning("Loop time exceeded, rescheduling immediately.")
elif self.ha.watch(nap_time):
self.next_run = time.time()
def run(self):
self.api.start()
self.next_run = time.time()
super(Patroni, self).run()
def _run_cycle(self):
logger.info(self.ha.run_cycle())
if self.dcs.cluster and self.dcs.cluster.config and self.dcs.cluster.config.data \
and self.config.set_dynamic_configuration(self.dcs.cluster.config):
self.reload_config()
if self.postgresql.role != 'uninitialized':
self.config.save_cache()
self.schedule_next_run()
def _shutdown(self):
try:
self.api.shutdown()
except Exception:
logger.exception('Exception during RestApi.shutdown')
try:
self.ha.shutdown()
except Exception:
logger.exception('Exception during Ha.shutdown')
def patroni_main():
from multiprocessing import freeze_support
from patroni.validator import schema
freeze_support()
abstract_main(Patroni, schema)
def main():
if os.getpid() != 1:
from . import check_psycopg
check_psycopg()
return patroni_main()
# Patroni started with PID=1, it looks like we are in the container
pid = 0
# Looks like we are in a docker, so we will act like init
def sigchld_handler(signo, stack_frame):
try:
while True:
ret = os.waitpid(-1, os.WNOHANG)
if ret == (0, 0):
break
elif ret[0] != pid:
logger.info('Reaped pid=%s, exit status=%s', *ret)
except OSError:
pass
def passtochild(signo, stack_frame):
if pid:
os.kill(pid, signo)
if os.name != 'nt':
signal.signal(signal.SIGCHLD, sigchld_handler)
signal.signal(signal.SIGHUP, passtochild)
signal.signal(signal.SIGQUIT, passtochild)
signal.signal(signal.SIGUSR1, passtochild)
signal.signal(signal.SIGUSR2, passtochild)
signal.signal(signal.SIGINT, passtochild)
signal.signal(signal.SIGABRT, passtochild)
signal.signal(signal.SIGTERM, passtochild)
import multiprocessing
patroni = multiprocessing.Process(target=patroni_main)
patroni.start()
pid = patroni.pid
patroni.join()
if __name__ == '__main__': if __name__ == '__main__':
+29 -9
View File
@@ -2,7 +2,6 @@ import base64
import hmac import hmac
import json import json
import logging import logging
import psycopg2
import time import time
import traceback import traceback
import dateutil.parser import dateutil.parser
@@ -18,6 +17,7 @@ from six.moves.socketserver import ThreadingMixIn
from six.moves.urllib_parse import urlparse, parse_qs from six.moves.urllib_parse import urlparse, parse_qs
from threading import Thread from threading import Thread
from . import psycopg
from .exceptions import PostgresConnectionException, PostgresException from .exceptions import PostgresConnectionException, PostgresException
from .postgresql.misc import postgres_version_to_int from .postgresql.misc import postgres_version_to_int
from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \ from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Retry, \
@@ -282,12 +282,27 @@ class RestApiHandler(BaseHTTPRequestHandler):
metrics.append("# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.") metrics.append("# HELP patroni_cluster_unlocked Value is 1 if the cluster is unlocked, 0 if locked.")
metrics.append("# TYPE patroni_cluster_unlocked gauge") metrics.append("# TYPE patroni_cluster_unlocked gauge")
metrics.append("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres['cluster_unlocked']))) metrics.append("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres.get('cluster_unlocked', 0))))
metrics.append("# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.") metrics.append("# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.")
metrics.append("# TYPE patroni_postgres_timeline counter") metrics.append("# TYPE patroni_postgres_timeline counter")
metrics.append("patroni_postgres_timeline{0} {1}".format(scope_label, postgres.get('timeline', 0))) metrics.append("patroni_postgres_timeline{0} {1}".format(scope_label, postgres.get('timeline', 0)))
metrics.append("# HELP patroni_dcs_last_seen Epoch timestamp when DCS was last contacted successfully"
" by Patroni.")
metrics.append("# TYPE patroni_dcs_last_seen gauge")
metrics.append("patroni_dcs_last_seen{0} {1}".format(scope_label, postgres.get('dcs_last_seen', 0)))
metrics.append("# HELP patroni_pending_restart Value is 1 if the node needs a restart, 0 otherwise.")
metrics.append("# TYPE patroni_pending_restart gauge")
metrics.append("patroni_pending_restart{0} {1}"
.format(scope_label, int(patroni.postgresql.pending_restart)))
metrics.append("# HELP patroni_is_paused Value is 1 if auto failover is disabled, 0 otherwise.")
metrics.append("# TYPE patroni_is_paused gauge")
metrics.append("patroni_is_paused{0} {1}"
.format(scope_label, int(patroni.ha.is_paused())))
self._write_response(200, '\n'.join(metrics)+'\n', content_type='text/plain') self._write_response(200, '\n'.join(metrics)+'\n', content_type='text/plain')
def _read_json_content(self, body_is_optional=False): def _read_json_content(self, body_is_optional=False):
@@ -599,7 +614,6 @@ class RestApiHandler(BaseHTTPRequestHandler):
'postmaster_start_time': row[0], 'postmaster_start_time': row[0],
'role': 'replica' if row[1] == 0 else 'master', 'role': 'replica' if row[1] == 0 else 'master',
'server_version': postgresql.server_version, 'server_version': postgresql.server_version,
'cluster_unlocked': bool(not cluster or cluster.is_unlocked()),
'xlog': ({ 'xlog': ({
'received_location': row[4] or row[3], 'received_location': row[4] or row[3],
'replayed_location': row[3], 'replayed_location': row[3],
@@ -621,13 +635,17 @@ class RestApiHandler(BaseHTTPRequestHandler):
if row[7]: if row[7]:
result['replication'] = row[7] result['replication'] = row[7]
return result except (psycopg.Error, RetryFailedError, PostgresConnectionException):
except (psycopg2.Error, RetryFailedError, PostgresConnectionException):
state = postgresql.state state = postgresql.state
if state == 'running': if state == 'running':
logger.exception('get_postgresql_status') logger.exception('get_postgresql_status')
state = 'unknown' state = 'unknown'
return {'state': state, 'role': postgresql.role} result = {'state': state, 'role': postgresql.role}
if not cluster or cluster.is_unlocked():
result['cluster_unlocked'] = True
result['dcs_last_seen'] = self.server.patroni.dcs.last_seen
return result
def handle_one_request(self): def handle_one_request(self):
self.__start_time = time.time() self.__start_time = time.time()
@@ -657,7 +675,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
with self.patroni.postgresql.connection().cursor() as cursor: with self.patroni.postgresql.connection().cursor() as cursor:
cursor.execute(sql, params) cursor.execute(sql, params)
return [r for r in cursor] return [r for r in cursor]
except psycopg2.Error as e: except psycopg.Error as e:
if cursor and cursor.connection.closed == 0: if cursor and cursor.connection.closed == 0:
raise e raise e
raise PostgresConnectionException('connection problems') raise PostgresConnectionException('connection problems')
@@ -760,6 +778,8 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
reloading_config = self.__listen is not None # changing config in runtime reloading_config = self.__listen is not None # changing config in runtime
if reloading_config: if reloading_config:
self.shutdown() self.shutdown()
# Rely on ThreadingMixIn.server_close() to have all requests terminate before we continue
self.server_close()
self.__listen = listen self.__listen = listen
self.__ssl_options = ssl_options self.__ssl_options = ssl_options
@@ -867,6 +887,6 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
@staticmethod @staticmethod
def handle_error(request, client_address): def handle_error(request, client_address):
address, port = client_address logger.warning('Exception happened during processing of request from %s:%s',
logger.warning('Exception happened during processing of request from {}:{}'.format(address, port)) client_address[0], client_address[1])
logger.warning(traceback.format_exc()) logger.warning(traceback.format_exc())
+4 -3
View File
@@ -24,6 +24,7 @@ _AUTH_ALLOWED_PARAMETERS = (
'sslpassword', 'sslpassword',
'sslrootcert', 'sslrootcert',
'sslcrl', 'sslcrl',
'sslcrldir',
'gssencmode', 'gssencmode',
'channel_binding' 'channel_binding'
) )
@@ -269,7 +270,7 @@ class Config(object):
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password', _set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
'cafile', 'ciphers', 'verify_client', 'http_extra_headers', 'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
'https_extra_headers', 'allowlist', 'allowlist_include_members']) 'https_extra_headers', 'allowlist', 'allowlist_include_members'])
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile']) _set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
_set_section_values('postgresql', ['listen', 'connect_address', 'config_dir', 'data_dir', 'pgpass', 'bin_dir']) _set_section_values('postgresql', ['listen', 'connect_address', 'config_dir', 'data_dir', 'pgpass', 'bin_dir'])
_set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size', _set_section_values('log', ['level', 'traceback_level', 'format', 'dateformat', 'max_queue_size',
'dir', 'file_size', 'file_num', 'loggers']) 'dir', 'file_size', 'file_num', 'loggers'])
@@ -351,13 +352,13 @@ class Config(object):
'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY', 'CACERT', 'CERT', 'KEY', 'VERIFY', 'TOKEN', 'CHECKS', 'DC', 'CONSISTENCY',
'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'NAMESPACE', 'CONTEXT', 'REGISTER_SERVICE', 'SERVICE_CHECK_INTERVAL', 'NAMESPACE', 'CONTEXT',
'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS', 'USE_ENDPOINTS', 'SCOPE_LABEL', 'ROLE_LABEL', 'POD_IP', 'PORTS', 'LABELS',
'BYPASS_API_SERVICE', 'KEY_PASSWORD', 'USE_SSL') and name: 'BYPASS_API_SERVICE', 'KEY_PASSWORD', 'USE_SSL', 'SET_ACLS') and name:
value = os.environ.pop(param) value = os.environ.pop(param)
if suffix == 'PORT': if suffix == 'PORT':
value = value and parse_int(value) value = value and parse_int(value)
elif suffix in ('HOSTS', 'PORTS', 'CHECKS'): elif suffix in ('HOSTS', 'PORTS', 'CHECKS'):
value = value and _parse_list(value) value = value and _parse_list(value)
elif suffix == 'LABELS': elif suffix in ('LABELS', 'SET_ACLS'):
value = _parse_dict(value) value = _parse_dict(value)
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'): elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
value = parse_bool(value) value = parse_bool(value)
+12 -12
View File
@@ -264,13 +264,13 @@ def get_cursor(cluster, connect_parameters, role='master', member=None):
params = member.conn_kwargs(connect_parameters) params = member.conn_kwargs(connect_parameters)
params.update({'fallback_application_name': 'Patroni ctl', 'connect_timeout': '5'}) params.update({'fallback_application_name': 'Patroni ctl', 'connect_timeout': '5'})
if 'database' in connect_parameters: if 'dbname' in connect_parameters:
params['database'] = connect_parameters['database'] params['dbname'] = connect_parameters['dbname']
else: else:
params.pop('database') params.pop('dbname')
import psycopg2 from . import psycopg
conn = psycopg2.connect(**params) conn = psycopg.connect(**params)
conn.autocommit = True conn.autocommit = True
cursor = conn.cursor() cursor = conn.cursor()
if role == 'any': if role == 'any':
@@ -401,7 +401,7 @@ def query(
if password: if password:
connect_parameters['password'] = click.prompt('Password', hide_input=True, type=str) connect_parameters['password'] = click.prompt('Password', hide_input=True, type=str)
if dbname: if dbname:
connect_parameters['database'] = dbname connect_parameters['dbname'] = dbname
if p_file is not None: if p_file is not None:
command = p_file.read() command = p_file.read()
@@ -418,7 +418,7 @@ def query(
def query_member(cluster, cursor, member, role, command, connect_parameters): def query_member(cluster, cursor, member, role, command, connect_parameters):
import psycopg2 from . import psycopg
try: try:
if cursor is None: if cursor is None:
cursor = get_cursor(cluster, connect_parameters, role=role, member=member) cursor = get_cursor(cluster, connect_parameters, role=role, member=member)
@@ -433,11 +433,11 @@ def query_member(cluster, cursor, member, role, command, connect_parameters):
cursor.execute(command) cursor.execute(command)
return cursor.fetchall(), [d.name for d in cursor.description] return cursor.fetchall(), [d.name for d in cursor.description]
except (psycopg2.OperationalError, psycopg2.DatabaseError) as oe: except psycopg.DatabaseError as de:
logging.debug(oe) logging.debug(de)
if cursor is not None and not cursor.connection.closed: if cursor is not None and not cursor.connection.closed:
cursor.connection.close() cursor.connection.close()
message = oe.pgcode or oe.pgerror or str(oe) message = de.diag.sqlstate or str(de)
message = message.replace('\n', ' ') message = message.replace('\n', ' ')
return [[timestamp(0), 'ERROR, SQLSTATE: {0}'.format(message)]], None return [[timestamp(0), 'ERROR, SQLSTATE: {0}'.format(message)]], None
@@ -1302,8 +1302,8 @@ def history(obj, cluster_name, fmt):
table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader'] table_header_row = ['TL', 'LSN', 'Reason', 'Timestamp', 'New Leader']
for line in history: for line in history:
if len(line) < len(table_header_row): if len(line) < len(table_header_row):
add_coloumn_num = len(table_header_row) - len(line) add_column_num = len(table_header_row) - len(line)
for _ in range(add_coloumn_num): for _ in range(add_column_num):
line.append('') line.append('')
print_output(table_header_row, history, {'TL': 'r', 'LSN': 'r'}, fmt) print_output(table_header_row, history, {'TL': 'r', 'LSN': 'r'}, fmt)
+22 -11
View File
@@ -160,7 +160,7 @@ class Member(namedtuple('Member', 'index,name,session,data')):
defaults = { defaults = {
"host": None, "host": None,
"port": None, "port": None,
"database": None "dbname": None
} }
ret = self.data.get('conn_kwargs') ret = self.data.get('conn_kwargs')
if ret: if ret:
@@ -174,7 +174,7 @@ class Member(namedtuple('Member', 'index,name,session,data')):
ret = { ret = {
'host': r.hostname, 'host': r.hostname,
'port': r.port or 5432, 'port': r.port or 5432,
'database': r.path[1:] 'dbname': r.path[1:]
} }
self.data['conn_kwargs'] = ret.copy() self.data['conn_kwargs'] = ret.copy()
@@ -460,8 +460,12 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,f
:param slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int} :param slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}
""" """
@property
def leader_name(self):
return self.leader and self.leader.name
def is_unlocked(self): def is_unlocked(self):
return not (self.leader and self.leader.name) return not self.leader_name
def has_member(self, member_name): def has_member(self, member_name):
return any(m for m in self.members if m.name == member_name) return any(m for m in self.members if m.name == member_name)
@@ -499,7 +503,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,f
@property @property
def use_slots(self): def use_slots(self):
return self.config and self.config.data.get('postgresql', {}).get('use_slots', True) return self.config and (self.config.data.get('postgresql') or {}).get('use_slots', True)
def get_replication_slots(self, my_name, role, nofailover, major_version, show_error=False): def get_replication_slots(self, my_name, role, nofailover, major_version, show_error=False):
# if the replicatefrom tag is set on the member - we should not create the replication slot for it on # if the replicatefrom tag is set on the member - we should not create the replication slot for it on
@@ -516,7 +520,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,f
else: else:
# only manage slots for replicas that replicate from this one, except for the leader among them # only manage slots for replicas that replicate from this one, except for the leader among them
slot_members = [m.name for m in self.members if use_slots and slot_members = [m.name for m in self.members if use_slots and
m.replicatefrom == my_name and m.name != self.leader.name] m.replicatefrom == my_name and m.name != self.leader_name]
permanent_slots = self.__permanent_logical_slots if use_slots and not nofailover else {} permanent_slots = self.__permanent_logical_slots if use_slots and not nofailover else {}
slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members} slots = {slot_name_from_member_name(name): {'type': 'physical'} for name in slot_members}
@@ -585,7 +589,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,f
return True return True
if self.use_slots: if self.use_slots:
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader.name] members = [m for m in self.members if m.replicatefrom == 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, major_version) for m in members)
return False return False
@@ -652,6 +656,7 @@ class AbstractDCS(object):
self._cluster_valid_till = 0 self._cluster_valid_till = 0
self._cluster_thread_lock = Lock() self._cluster_thread_lock = Lock()
self._last_lsn = '' self._last_lsn = ''
self._last_seen = 0
self._last_status = {} self._last_status = {}
self.event = Event() self.event = Event()
@@ -722,6 +727,10 @@ class AbstractDCS(object):
def loop_wait(self): def loop_wait(self):
return self._loop_wait return self._loop_wait
@property
def last_seen(self):
return self._last_seen
@abc.abstractmethod @abc.abstractmethod
def _load_cluster(self): def _load_cluster(self):
"""Internally this method should build `Cluster` object which """Internally this method should build `Cluster` object which
@@ -744,6 +753,8 @@ class AbstractDCS(object):
self.reset_cluster() self.reset_cluster()
raise raise
self._last_seen = int(time.time())
with self._cluster_thread_lock: with self._cluster_thread_lock:
self._cluster = cluster self._cluster = cluster
self._cluster_valid_till = time.time() + self.ttl self._cluster_valid_till = time.time() + self.ttl
@@ -767,8 +778,7 @@ class AbstractDCS(object):
:returns: `!True` on success.""" :returns: `!True` on success."""
def write_leader_optime(self, last_lsn): def write_leader_optime(self, last_lsn):
if self._last_lsn != last_lsn and self._write_leader_optime(last_lsn): self.write_status({self._OPTIME: last_lsn})
self._last_lsn = last_lsn
@abc.abstractmethod @abc.abstractmethod
def _write_status(self, value): def _write_status(self, value):
@@ -782,7 +792,8 @@ class AbstractDCS(object):
self._last_status = value self._last_status = value
cluster = self.cluster cluster = self.cluster
min_version = cluster and cluster.min_version min_version = cluster and cluster.min_version
if min_version and min_version < (2, 1, 0): if min_version and min_version < (2, 1, 0) and self._last_lsn != value[self._OPTIME]:
self._last_lsn = value[self._OPTIME]
self._write_leader_optime(str(value[self._OPTIME])) self._write_leader_optime(str(value[self._OPTIME]))
@abc.abstractmethod @abc.abstractmethod
@@ -883,7 +894,7 @@ class AbstractDCS(object):
:param last_lsn: latest checkpoint location in bytes""" :param last_lsn: latest checkpoint location in bytes"""
if last_lsn: if last_lsn:
self.write_leader_optime(last_lsn) self.write_status({self._OPTIME: last_lsn})
return self._delete_leader() return self._delete_leader()
@abc.abstractmethod @abc.abstractmethod
@@ -926,4 +937,4 @@ class AbstractDCS(object):
:returns: `!True` if you would like to reschedule the next run of ha cycle""" :returns: `!True` if you would like to reschedule the next run of ha cycle"""
self.event.wait(timeout) self.event.wait(timeout)
return self.event.isSet() return self.event.is_set()
+5 -3
View File
@@ -184,7 +184,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
for base_uri in machines_cache: for base_uri in machines_cache:
try: try:
machines = list(self._get_members(base_uri, **kwargs)) machines = list(set(self._get_members(base_uri, **kwargs)))
logger.debug("Retrieved list of machines: %s", machines) logger.debug("Retrieved list of machines: %s", machines)
if machines: if machines:
random.shuffle(machines) random.shuffle(machines)
@@ -269,6 +269,7 @@ class AbstractEtcdClientWithFailover(etcd.Client):
nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time) nodes, timeout, retries = self._calculate_timeouts(etcd_nodes, remaining_time)
if nodes == 0: if nodes == 0:
self._update_machines_cache = True self._update_machines_cache = True
self.set_base_uri(self._base_uri) # trigger Etcd3 watcher restart
raise ex raise ex
retry.sleep_func(sleeptime) retry.sleep_func(sleeptime)
retry.update_delay() retry.update_delay()
@@ -394,8 +395,9 @@ class AbstractEtcdClientWithFailover(etcd.Client):
self._machines_cache_updated = time.time() self._machines_cache_updated = time.time()
def set_base_uri(self, value): def set_base_uri(self, value):
logger.info('Selected new etcd server %s', value) if self._base_uri != value:
self._base_uri = value logger.info('Selected new etcd server %s', value)
self._base_uri = value
class EtcdClient(AbstractEtcdClientWithFailover): class EtcdClient(AbstractEtcdClientWithFailover):
+5 -5
View File
@@ -345,12 +345,12 @@ class K8sClient(object):
try: try:
self._load_api_servers_cache() self._load_api_servers_cache()
api_servers_cache = self.api_servers_cache api_servers_cache = self.api_servers_cache
api_servers = len(api_servers) api_servers = len(api_servers_cache)
except Exception as e: except Exception as e:
logger.debug('Failed to update list of K8s master nodes: %r', e) logger.debug('Failed to update list of K8s master nodes: %r', e)
sleeptime = retry.sleeptime sleeptime = retry.sleeptime
remaining_time = retry.stoptime - sleeptime - time.time() remaining_time = (retry.stoptime or time.time()) - sleeptime - time.time()
nodes, timeout, retries = self._calculate_timeouts(api_servers, remaining_time) nodes, timeout, retries = self._calculate_timeouts(api_servers, remaining_time)
if nodes == 0: if nodes == 0:
self._update_api_servers_cache = True self._update_api_servers_cache = True
@@ -927,7 +927,7 @@ class Kubernetes(AbstractDCS):
# Try to get the latest version directly from K8s API instead of relying on async cache # Try to get the latest version directly from K8s API instead of relying on async cache
try: try:
kind = retry(self._api.read_namespaced_kind, self.leader_path, self._namespace) kind = _retry(self._api.read_namespaced_kind, self.leader_path, self._namespace)
except Exception as e: except Exception as e:
logger.error('Failed to get the leader object "%s": %r', self.leader_path, e) logger.error('Failed to get the leader object "%s": %r', self.leader_path, e)
return False return False
@@ -1011,7 +1011,7 @@ class Kubernetes(AbstractDCS):
def touch_member(self, data, permanent=False): def touch_member(self, data, permanent=False):
cluster = self.cluster cluster = self.cluster
if cluster and cluster.leader and cluster.leader.name == self._name: if cluster and cluster.leader and cluster.leader.name == self._name:
role = 'promoted' if data['role'] in ('replica', 'promoted') else 'master' role = 'master'
elif data['state'] == 'running' and data['role'] != 'master': elif data['state'] == 'running' and data['role'] != 'master':
role = data['role'] role = data['role']
else: else:
@@ -1050,7 +1050,7 @@ class Kubernetes(AbstractDCS):
self.reset_cluster() self.reset_cluster()
def cancel_initialization(self): def cancel_initialization(self):
self.patch_or_create_config({self._INITIALIZE: None}, self._config_resource_version, True) return self.patch_or_create_config({self._INITIALIZE: None}, None, True)
@catch_kubernetes_errors @catch_kubernetes_errors
def delete_cluster(self): def delete_cluster(self):
+1 -1
View File
@@ -271,7 +271,7 @@ class Raft(AbstractDCS):
while True: while True:
ready_event.wait(5) ready_event.wait(5)
if ready_event.isSet() or self._sync_obj.applied_local_log: if ready_event.is_set() or self._sync_obj.applied_local_log:
break break
else: else:
logger.info('waiting on raft') logger.info('waiting on raft')
+36 -15
View File
@@ -7,6 +7,7 @@ from kazoo.client import KazooClient, KazooState, KazooRetry
from kazoo.exceptions import NoNodeError, NodeExistsError, SessionExpiredError from kazoo.exceptions import NoNodeError, NodeExistsError, SessionExpiredError
from kazoo.handlers.threading import SequentialThreadingHandler from kazoo.handlers.threading import SequentialThreadingHandler
from kazoo.protocol.states import KeeperState from kazoo.protocol.states import KeeperState
from kazoo.security import make_acl
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from ..exceptions import DCSError from ..exceptions import DCSError
@@ -83,6 +84,19 @@ class ZooKeeper(AbstractDCS):
'cert': 'certfile', 'key': 'keyfile', 'key_password': 'keyfile_password'} 'cert': 'certfile', 'key': 'keyfile', 'key_password': 'keyfile_password'}
kwargs = {v: config[k] for k, v in mapping.items() if k in config} kwargs = {v: config[k] for k, v in mapping.items() if k in config}
if 'set_acls' in config:
kwargs['default_acl'] = []
for principal, permissions in config['set_acls'].items():
normalizedPermissions = [p.upper() for p in permissions]
kwargs['default_acl'].append(make_acl(scheme='x509',
credential=principal,
read='READ' in normalizedPermissions,
write='WRITE' in normalizedPermissions,
create='CREATE' in normalizedPermissions,
delete='DELETE' in normalizedPermissions,
admin='ADMIN' in normalizedPermissions,
all='ALL' in normalizedPermissions))
self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']), self._client = PatroniKazooClient(hosts, handler=PatroniSequentialThreadingHandler(config['retry_timeout']),
timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1, timeout=config['ttl'], connection_retry=KazooRetry(max_delay=1, max_tries=-1,
sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1, sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
@@ -91,6 +105,7 @@ class ZooKeeper(AbstractDCS):
self._fetch_cluster = True self._fetch_cluster = True
self._fetch_status = True self._fetch_status = True
self.__last_member_data = None
self._orig_kazoo_connect = self._client._connection._connect self._orig_kazoo_connect = self._client._connection._connect
self._client._connection._connect = self._kazoo_connect self._client._connection._connect = self._kazoo_connect
@@ -268,17 +283,20 @@ class ZooKeeper(AbstractDCS):
logger.exception('get_cluster') logger.exception('get_cluster')
self.cluster_watcher(None) self.cluster_watcher(None)
raise ZooKeeperError('ZooKeeper in not responding properly') raise ZooKeeperError('ZooKeeper in not responding properly')
# The /status ZNode was updated or doesn't exist and we are not leader # The /status ZNode was updated or doesn't exist
elif (self._fetch_status and not self._fetch_cluster or not cluster.last_lsn 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) and\ or cluster.has_permanent_logical_slots(self._name, False) and not cluster.slots:
not (cluster.leader and cluster.leader.name == self._name): # If current node is the leader just clear the event without fetching anything (we are updating the /status)
try: if cluster.leader and cluster.leader.name == self._name:
last_lsn, slots = self.get_status(cluster.leader)
self.event.clear() self.event.clear()
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, last_lsn, else:
cluster.members, cluster.failover, cluster.sync, cluster.history, slots) try:
except Exception: last_lsn, slots = self.get_status(cluster.leader)
pass self.event.clear()
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, last_lsn,
cluster.members, cluster.failover, cluster.sync, cluster.history, slots)
except Exception:
pass
return cluster return cluster
def _bypass_caches(self): def _bypass_caches(self):
@@ -334,11 +352,11 @@ class ZooKeeper(AbstractDCS):
def touch_member(self, data, permanent=False): def touch_member(self, data, permanent=False):
cluster = self.cluster cluster = self.cluster
member = cluster and cluster.get_member(self._name, fallback_to_leader=False) member = cluster and cluster.get_member(self._name, fallback_to_leader=False)
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8') member_data = self.__last_member_data or member and member.data
if member and (self._client.client_id is not None and member.session != self._client.client_id[0] or if member and (self._client.client_id is not None and member.session != self._client.client_id[0] or
not (deep_compare(member.data.get('tags', {}), data.get('tags', {})) and not (deep_compare(member_data.get('tags', {}), data.get('tags', {})) and
member.data.get('version') == data.get('version') and member_data.get('version') == data.get('version') and
member.data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))): member_data.get('checkpoint_after_promote') == data.get('checkpoint_after_promote'))):
try: try:
self._client.delete_async(self.member_path).get(timeout=1) self._client.delete_async(self.member_path).get(timeout=1)
except NoNodeError: except NoNodeError:
@@ -347,13 +365,15 @@ class ZooKeeper(AbstractDCS):
return False return False
member = None member = None
encoded_data = json.dumps(data, separators=(',', ':')).encode('utf-8')
if member: if member:
if deep_compare(data, member.data): if deep_compare(data, member_data):
return True return True
else: else:
try: try:
self._client.create_async(self.member_path, encoded_data, makepath=True, self._client.create_async(self.member_path, encoded_data, makepath=True,
ephemeral=not permanent).get(timeout=1) ephemeral=not permanent).get(timeout=1)
self.__last_member_data = data
return True return True
except Exception as e: except Exception as e:
if not isinstance(e, NodeExistsError): if not isinstance(e, NodeExistsError):
@@ -361,6 +381,7 @@ class ZooKeeper(AbstractDCS):
return False return False
try: try:
self._client.set_async(self.member_path, encoded_data).get(timeout=1) self._client.set_async(self.member_path, encoded_data).get(timeout=1)
self.__last_member_data = data
return True return True
except Exception: except Exception:
logger.exception('touch_member') logger.exception('touch_member')
+86 -37
View File
@@ -2,32 +2,36 @@ import datetime
import functools import functools
import json import json
import logging import logging
import psycopg2 import six
import sys import sys
import time import time
import uuid import uuid
from collections import namedtuple from collections import namedtuple
from multiprocessing.pool import ThreadPool from multiprocessing.pool import ThreadPool
from patroni.async_executor import AsyncExecutor, CriticalTask
from patroni.exceptions import DCSError, PostgresConnectionException, PatroniFatalException
from patroni.postgresql import ACTION_ON_START, ACTION_ON_ROLE_CHANGE
from patroni.postgresql.misc import postgres_version_to_int
from patroni.postgresql.rewind import Rewind
from patroni.utils import polling_loop, tzutc, is_standby_cluster as _is_standby_cluster, parse_int
from patroni.dcs import RemoteMember
from threading import RLock from threading import RLock
from . import psycopg
from .async_executor import AsyncExecutor, CriticalTask
from .exceptions import DCSError, PostgresConnectionException, PatroniFatalException
from .postgresql import ACTION_ON_START, ACTION_ON_ROLE_CHANGE
from .postgresql.misc import postgres_version_to_int
from .postgresql.rewind import Rewind
from .utils import polling_loop, tzutc, is_standby_cluster as _is_standby_cluster, parse_int
from .dcs import RemoteMember
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_recovery', 'timeline', class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_recovery',
'wal_position', 'tags', 'watchdog_failed'])): 'dcs_last_seen', 'timeline', 'wal_position',
'tags', 'watchdog_failed'])):
"""Node status distilled from API response: """Node status distilled from API response:
member - dcs.Member object of the node member - dcs.Member object of the node
reachable - `!False` if the node is not reachable or is not responding with correct JSON reachable - `!False` if the node is not reachable or is not responding with correct JSON
in_recovery - `!True` if pg_is_in_recovery() == true in_recovery - `!True` if pg_is_in_recovery() == true
dcs_last_seen - timestamp from JSON of last succesful communication with DCS
timeline - timeline value from JSON timeline - timeline value from JSON
wal_position - maximum value of `replayed_location` or `received_location` from JSON wal_position - maximum value of `replayed_location` or `received_location` from JSON
tags - dictionary with values of different tags (i.e. nofailover) tags - dictionary with values of different tags (i.e. nofailover)
@@ -37,12 +41,14 @@ class _MemberStatus(namedtuple('_MemberStatus', ['member', 'reachable', 'in_reco
def from_api_response(cls, member, json): def from_api_response(cls, member, json):
is_master = json['role'] == 'master' is_master = json['role'] == 'master'
timeline = json.get('timeline', 0) timeline = json.get('timeline', 0)
dcs_last_seen = json.get('dcs_last_seen', 0)
wal = not is_master and max(json['xlog'].get('received_location', 0), json['xlog'].get('replayed_location', 0)) wal = not is_master and max(json['xlog'].get('received_location', 0), json['xlog'].get('replayed_location', 0))
return cls(member, True, not is_master, timeline, wal, json.get('tags', {}), json.get('watchdog_failed', False)) return cls(member, True, not is_master, dcs_last_seen, timeline, wal,
json.get('tags', {}), json.get('watchdog_failed', False))
@classmethod @classmethod
def unknown(cls, member): def unknown(cls, member):
return cls(member, False, None, 0, 0, {}, False) return cls(member, False, None, 0, 0, 0, {}, False)
def failover_limitation(self): def failover_limitation(self):
"""Returns reason why this node can't promote or None if everything is ok.""" """Returns reason why this node can't promote or None if everything is ok."""
@@ -186,9 +192,6 @@ class Ha(object):
'version': self.patroni.version 'version': self.patroni.version
} }
# following two lines are mainly necessary for consul, to avoid creation of master service
if data['role'] == 'master' and not self.is_leader():
data['role'] = 'promoted'
if self.is_leader() and not self._rewind.checkpoint_after_promote(): if self.is_leader() and not self._rewind.checkpoint_after_promote():
data['checkpoint_after_promote'] = False data['checkpoint_after_promote'] = False
tags = self.get_effective_tags() tags = self.get_effective_tags()
@@ -238,7 +241,7 @@ class Ha(object):
logger.info('bootstrapped %s', msg) logger.info('bootstrapped %s', msg)
cluster = self.dcs.get_cluster() cluster = self.dcs.get_cluster()
node_to_follow = self._get_node_to_follow(cluster) node_to_follow = self._get_node_to_follow(cluster)
return self.state_handler.follow(node_to_follow) return self.state_handler.follow(node_to_follow) is not False
else: else:
logger.error('failed to bootstrap %s', msg) logger.error('failed to bootstrap %s', msg)
self.state_handler.remove_data_directory() self.state_handler.remove_data_directory()
@@ -313,10 +316,7 @@ class Ha(object):
if timeout == 0: if timeout == 0:
# We are requested to prefer failing over to restarting master. But see first if there # We are requested to prefer failing over to restarting master. But see first if there
# is anyone to fail over to. # is anyone to fail over to.
members = self.cluster.members if self.is_failover_possible(self.cluster.members):
if self.is_synchronous_mode():
members = [m for m in members if self.cluster.sync.matches(m.name)]
if self.is_failover_possible(members):
logger.info("Master crashed. Failing over.") logger.info("Master crashed. Failing over.")
self.demote('immediate') self.demote('immediate')
return 'stopped PostgreSQL to fail over after a crash' return 'stopped PostgreSQL to fail over after a crash'
@@ -686,16 +686,21 @@ class Ha(object):
logger.info('Ignoring the former leader being ahead of us') logger.info('Ignoring the former leader being ahead of us')
return True return True
def is_failover_possible(self, members): def is_failover_possible(self, members, check_synchronous=True, cluster_lsn=None):
ret = False ret = False
cluster_timeline = self.cluster.timeline cluster_timeline = self.cluster.timeline
members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url] members = [m for m in members if m.name != self.state_handler.name and not m.nofailover and m.api_url]
if check_synchronous and self.is_synchronous_mode():
members = [m for m in members if self.cluster.sync.matches(m.name)]
if members: if members:
for st in self.fetch_nodes_statuses(members): for st in self.fetch_nodes_statuses(members):
not_allowed_reason = st.failover_limitation() not_allowed_reason = st.failover_limitation()
if not_allowed_reason: if not_allowed_reason:
logger.info('Member %s is %s', st.member.name, not_allowed_reason) logger.info('Member %s is %s', st.member.name, not_allowed_reason)
elif self.is_lagging(st.wal_position): elif not isinstance(st.wal_position, six.integer_types):
logger.info('Member %s does not report wal_position', st.member.name)
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) logger.info('Member %s exceeds maximum replication lag', st.member.name)
elif self.check_timeline() and (not st.timeline or st.timeline < cluster_timeline): 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', logger.info('Timeline %s of member %s is behind the cluster timeline %s',
@@ -779,6 +784,10 @@ class Ha(object):
return False return False
if self.cluster.failover: if self.cluster.failover:
# When doing a switchover in synchronous mode only synchronous nodes and former leader are allowed to race
if self.is_synchronous_mode() and self.cluster.failover.leader and \
self.cluster.failover.candidate and not self.cluster.sync.matches(self.state_handler.name):
return False
return self.manual_failover_process_no_leader() return self.manual_failover_process_no_leader()
if not self.watchdog.is_healthy: if not self.watchdog.is_healthy:
@@ -787,7 +796,7 @@ class Ha(object):
# When in sync mode, only last known master and sync standby are allowed to promote automatically. # When in sync mode, only last known master and sync standby are allowed to promote automatically.
all_known_members = self.cluster.members + self.old_cluster.members all_known_members = self.cluster.members + self.old_cluster.members
if self.is_synchronous_mode() and self.cluster.sync.leader: if self.is_synchronous_mode() and self.cluster.sync and self.cluster.sync.leader:
if not self.cluster.sync.matches(self.state_handler.name): if not self.cluster.sync.matches(self.state_handler.name):
return False return False
# pick between synchronous candidates so we minimize unnecessary failovers/demotions # pick between synchronous candidates so we minimize unnecessary failovers/demotions
@@ -826,23 +835,44 @@ class Ha(object):
'immediate-nolock': dict(stop='immediate', checkpoint=False, release=False, offline=False, async_req=True), 'immediate-nolock': dict(stop='immediate', checkpoint=False, release=False, offline=False, async_req=True),
}[mode] }[mode]
logger.info('Demoting self (%s)', mode)
self._rewind.trigger_check_diverged_lsn() self._rewind.trigger_check_diverged_lsn()
status = {'released': False}
def on_shutdown(checkpoint_location):
# Postmaster is still running, but pg_control already reports clean "shut down".
# It could happen if Postgres is still archiving the backlog of WAL files.
# If we know that there are replicas that received the shutdown checkpoint
# location, we can remove the leader key and allow them to start leader race.
if self.is_failover_possible(self.cluster.members, cluster_lsn=checkpoint_location):
self.state_handler.set_role('demoted')
with self._async_executor:
self.release_leader_key_voluntarily(checkpoint_location)
status['released'] = True
self.state_handler.stop(mode_control['stop'], checkpoint=mode_control['checkpoint'], self.state_handler.stop(mode_control['stop'], checkpoint=mode_control['checkpoint'],
on_safepoint=self.watchdog.disable if self.watchdog.is_running else None, on_safepoint=self.watchdog.disable if self.watchdog.is_running else None,
on_shutdown=on_shutdown if mode_control['release'] else None,
stop_timeout=self.master_stop_timeout()) stop_timeout=self.master_stop_timeout())
self.state_handler.set_role('demoted') self.state_handler.set_role('demoted')
self.set_is_leader(False) self.set_is_leader(False)
if mode_control['release']: if mode_control['release']:
checkpoint_location = self.state_handler.latest_checkpoint_location() if mode == 'graceful' else None if not status['released']:
with self._async_executor: checkpoint_location = self.state_handler.latest_checkpoint_location() if mode == 'graceful' else None
self.release_leader_key_voluntarily(checkpoint_location) with self._async_executor:
self.release_leader_key_voluntarily(checkpoint_location)
time.sleep(2) # Give a time to somebody to take the leader lock time.sleep(2) # Give a time to somebody to take the leader lock
if mode_control['offline']: if mode_control['offline']:
node_to_follow, leader = None, None node_to_follow, leader = None, None
else: else:
cluster = self.dcs.get_cluster() try:
node_to_follow, leader = self._get_node_to_follow(cluster), cluster.leader cluster = self.dcs.get_cluster()
node_to_follow, leader = self._get_node_to_follow(cluster), cluster.leader
except Exception:
node_to_follow, leader = None, None
# FIXME: with mode offline called from DCS exception handler and handle_long_action_in_progress # FIXME: with mode offline called from DCS exception handler and handle_long_action_in_progress
# there could be an async action already running, calling follow from here will lead # there could be an async action already running, calling follow from here will lead
@@ -920,7 +950,7 @@ class Ha(object):
else: else:
members = [m for m in self.cluster.members members = [m for m in self.cluster.members
if not failover.candidate or m.name == failover.candidate] if not failover.candidate or m.name == failover.candidate]
if self.is_failover_possible(members): # check that there are healthy members if self.is_failover_possible(members, False): # check that there are healthy members
ret = self._async_executor.try_run_async('manual failover: demote', self.demote, ('graceful',)) ret = self._async_executor.try_run_async('manual failover: demote', self.demote, ('graceful',))
return ret or 'manual failover: demoting myself' return ret or 'manual failover: demoting myself'
else: else:
@@ -982,8 +1012,9 @@ class Ha(object):
if self.cluster.failover and self.cluster.failover.candidate == self.state_handler.name: if self.cluster.failover and self.cluster.failover.candidate == self.state_handler.name:
return 'waiting to become master after promote...' return 'waiting to become master after promote...'
self._delete_leader() if not self.is_standby_cluster():
return 'removed leader lock because postgres is not running as master' self._delete_leader()
return 'removed leader lock because postgres is not running as master'
if self.update_lock(True): if self.update_lock(True):
msg = self.process_manual_failover_from_leader() msg = self.process_manual_failover_from_leader()
@@ -997,13 +1028,13 @@ class Ha(object):
# in case of standby cluster we don't really need to # in case of standby cluster we don't really need to
# enforce anything, since the leader is not a master. # enforce anything, since the leader is not a master.
# So just remind the role. # So just remind the role.
msg = 'no action. I am ({0}) the standby leader with the lock'.format(self.state_handler.name) \ msg = 'no action. I am ({0}), the standby leader with the lock'.format(self.state_handler.name) \
if self.state_handler.role == 'standby_leader' else \ if self.state_handler.role == 'standby_leader' else \
'promoted self to a standby leader because i had the session lock' 'promoted self to a standby leader because i had the session lock'
return self.enforce_follow_remote_master(msg) return self.enforce_follow_remote_master(msg)
else: else:
return self.enforce_master_role( return self.enforce_master_role(
'no action. I am ({0}) the leader with the lock'.format(self.state_handler.name), 'no action. I am ({0}), the leader with the lock'.format(self.state_handler.name),
'promoted self to leader because I had the session lock' 'promoted self to leader because I had the session lock'
) )
else: else:
@@ -1021,10 +1052,10 @@ class Ha(object):
lock_owner = self.cluster.leader and self.cluster.leader.name lock_owner = self.cluster.leader and self.cluster.leader.name
if self.is_standby_cluster(): if self.is_standby_cluster():
return self.follow('cannot be a real primary in a standby cluster', return self.follow('cannot be a real primary in a standby cluster',
'no action. I am a secondary ({0}) and following a standby leader ({1})'.format( 'no action. I am ({0}), a secondary, and following a standby leader ({1})'.format(
self.state_handler.name, lock_owner), refresh=False) self.state_handler.name, lock_owner), refresh=False)
return self.follow('demoting self because I do not have the lock and I was a leader', return self.follow('demoting self because I do not have the lock and I was a leader',
'no action. I am a secondary ({0}) and following a leader ({1})'.format( 'no action. I am ({0}), a secondary, and following a leader ({1})'.format(
self.state_handler.name, lock_owner), refresh=False) self.state_handler.name, lock_owner), refresh=False)
def evaluate_scheduled_restart(self): def evaluate_scheduled_restart(self):
@@ -1250,6 +1281,7 @@ class Ha(object):
if not self.watchdog.activate(): if not self.watchdog.activate():
logger.error('Cancelling bootstrap because watchdog activation failed') logger.error('Cancelling bootstrap because watchdog activation failed')
self.cancel_initialization() self.cancel_initialization()
self._rewind.ensure_checkpoint_after_promote(self.wakeup)
self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid) self.dcs.initialize(create_new=(self.cluster.initialize is None), sysid=self.state_handler.sysid)
self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':'))) self.dcs.set_config_value(json.dumps(self.patroni.config.dynamic_configuration, separators=(',', ':')))
self.dcs.take_leader() self.dcs.take_leader()
@@ -1459,7 +1491,7 @@ class Ha(object):
self.demote('offline') self.demote('offline')
return 'demoted self because DCS is not accessible and i was a leader' return 'demoted self because DCS is not accessible and i was a leader'
return 'DCS is not accessible' return 'DCS is not accessible'
except (psycopg2.Error, PostgresConnectionException): except (psycopg.Error, PostgresConnectionException):
return 'Error communicating with PostgreSQL. Will try again later' return 'Error communicating with PostgreSQL. Will try again later'
finally: finally:
if not dcs_failed: if not dcs_failed:
@@ -1486,10 +1518,27 @@ class Ha(object):
# This might not be the desired behavior of users, as a graceful shutdown of the host can mean lost data. # This might not be the desired behavior of users, as a graceful shutdown of the host can mean lost data.
# We probably need to something smarter here. # We probably need to something smarter here.
disable_wd = self.watchdog.disable if self.watchdog.is_running else None disable_wd = self.watchdog.disable if self.watchdog.is_running else None
status = {'deleted': False}
def _on_shutdown(checkpoint_location):
if self.is_leader():
# Postmaster is still running, but pg_control already reports clean "shut down".
# It could happen if Postgres is still archiving the backlog of WAL files.
# If we know that there are replicas that received the shutdown checkpoint
# location, we can remove the leader key and allow them to start leader race.
if self.is_failover_possible(self.cluster.members, cluster_lsn=checkpoint_location):
self.dcs.delete_leader(checkpoint_location)
status['deleted'] = True
else:
self.dcs.write_leader_optime(checkpoint_location)
on_shutdown = _on_shutdown if self.is_leader() else None
self.while_not_sync_standby(lambda: self.state_handler.stop(checkpoint=False, on_safepoint=disable_wd, self.while_not_sync_standby(lambda: self.state_handler.stop(checkpoint=False, on_safepoint=disable_wd,
on_shutdown=on_shutdown,
stop_timeout=self.master_stop_timeout())) stop_timeout=self.master_stop_timeout()))
if not self.state_handler.is_running(): if not self.state_handler.is_running():
if self.is_leader(): if self.is_leader() and not status['deleted']:
checkpoint_location = self.state_handler.latest_checkpoint_location() checkpoint_location = self.state_handler.latest_checkpoint_location()
self.dcs.delete_leader(checkpoint_location) self.dcs.delete_leader(checkpoint_location)
self.touch_member() self.touch_member()
+51 -20
View File
@@ -1,6 +1,5 @@
import logging import logging
import os import os
import psycopg2
import re import re
import shlex import shlex
import shutil import shutil
@@ -23,6 +22,7 @@ from .connection import Connection, get_connection_cursor
from .misc import parse_history, parse_lsn, postgres_major_version_to_int from .misc import parse_history, parse_lsn, postgres_major_version_to_int
from .postmaster import PostmasterProcess from .postmaster import PostmasterProcess
from .slots import SlotsHandler from .slots import SlotsHandler
from .. import psycopg
from ..exceptions import PostgresConnectionException from ..exceptions import PostgresConnectionException
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
@@ -264,15 +264,15 @@ class Postgresql(object):
cursor = None cursor = None
try: try:
cursor = self._connection.cursor() cursor = self._connection.cursor()
cursor.execute(sql, params) cursor.execute(sql, params or None)
return cursor return cursor
except psycopg2.Error as e: except psycopg.Error as e:
if cursor and cursor.connection.closed == 0: if cursor and cursor.connection.closed == 0:
# When connected via unix socket, psycopg2 can't recoginze 'connection lost' # When connected via unix socket, psycopg2 can't recoginze 'connection lost'
# and leaves `_cursor_holder.connection.closed == 0`, but psycopg2.OperationalError # and leaves `_cursor_holder.connection.closed == 0`, but psycopg2.OperationalError
# is still raised (what is correct). It doesn't make sense to continiue with existing # is still raised (what is correct). It doesn't make sense to continiue with existing
# connection and we will close it, to avoid its reuse by the `cursor` method. # connection and we will close it, to avoid its reuse by the `cursor` method.
if isinstance(e, psycopg2.OperationalError): if isinstance(e, psycopg.OperationalError):
self._connection.close() self._connection.close()
else: else:
raise e raise e
@@ -327,6 +327,9 @@ class Postgresql(object):
if cluster and cluster.config and cluster.config.modify_index: if cluster and cluster.config and cluster.config.modify_index:
self._has_permanent_logical_slots =\ self._has_permanent_logical_slots =\
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version) cluster.has_permanent_logical_slots(self.name, nofailover, self.major_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.set_enforce_hot_standby_feedback(
self._has_permanent_logical_slots or self._has_permanent_logical_slots or
cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version)) cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
@@ -338,7 +341,9 @@ class Postgresql(object):
cluster_info_state = dict(zip(['timeline', 'wal_position', 'replayed_location', cluster_info_state = dict(zip(['timeline', 'wal_position', 'replayed_location',
'received_location', 'replay_paused', 'pg_control_timeline', 'received_location', 'replay_paused', 'pg_control_timeline',
'received_tli', 'slot_name', 'conninfo', 'slots'], result)) 'received_tli', 'slot_name', 'conninfo', 'slots'], result))
cluster_info_state['slots'] = self.slots_handler.process_permanent_slots(cluster_info_state['slots']) if self._has_permanent_logical_slots:
cluster_info_state['slots'] =\
self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
self._cluster_info_state = cluster_info_state self._cluster_info_state = cluster_info_state
except RetryFailedError as e: # SELECT failed two times except RetryFailedError as e: # SELECT failed two times
self._cluster_info_state = {'error': str(e)} self._cluster_info_state = {'error': str(e)}
@@ -369,7 +374,11 @@ class Postgresql(object):
return self._cluster_info_state_get('received_tli') return self._cluster_info_state_get('received_tli')
def is_leader(self): def is_leader(self):
return bool(self._cluster_info_state_get('timeline')) try:
return bool(self._cluster_info_state_get('timeline'))
except PostgresConnectionException:
logger.warning('Failed to determine PostgreSQL state from the connection, falling back to cached role')
return bool(self.is_running() and self.role == 'master')
def replay_paused(self): def replay_paused(self):
return self._cluster_info_state_get('replay_paused') return self._cluster_info_state_get('replay_paused')
@@ -384,6 +393,7 @@ class Postgresql(object):
def pg_control_timeline(self): def pg_control_timeline(self):
try: try:
return int(self.controldata().get("Latest checkpoint's TimeLineID")) return int(self.controldata().get("Latest checkpoint's TimeLineID"))
except (TypeError, ValueError): except (TypeError, ValueError):
logger.exception('Failed to parse timeline from pg_controldata output') logger.exception('Failed to parse timeline from pg_controldata output')
@@ -593,12 +603,13 @@ class Postgresql(object):
cur.execute('SELECT pg_catalog.pg_is_in_recovery()') cur.execute('SELECT pg_catalog.pg_is_in_recovery()')
if cur.fetchone()[0]: if cur.fetchone()[0]:
return 'is_in_recovery=true' return 'is_in_recovery=true'
return cur.execute('CHECKPOINT') cur.execute('CHECKPOINT')
except psycopg2.Error: except psycopg.Error:
logger.exception('Exception during CHECKPOINT') logger.exception('Exception during CHECKPOINT')
return 'not accessible or not healty' return 'not accessible or not healty'
def stop(self, mode='fast', block_callbacks=False, checkpoint=None, on_safepoint=None, stop_timeout=None): def stop(self, mode='fast', block_callbacks=False, checkpoint=None,
on_safepoint=None, on_shutdown=None, stop_timeout=None):
"""Stop PostgreSQL """Stop PostgreSQL
Supports a callback when a safepoint is reached. A safepoint is when no user backend can return a successful Supports a callback when a safepoint is reached. A safepoint is when no user backend can return a successful
@@ -606,11 +617,12 @@ class Postgresql(object):
could be added. could be added.
:param on_safepoint: This callback is called when no user backends are running. :param on_safepoint: This callback is called when no user backends are running.
:param on_shutdown: is called when pg_controldata starts reporting `Database cluster state: shut down`
""" """
if checkpoint is None: if checkpoint is None:
checkpoint = False if mode == 'immediate' else True checkpoint = False if mode == 'immediate' else True
success, pg_signaled = self._do_stop(mode, block_callbacks, checkpoint, on_safepoint, stop_timeout) success, pg_signaled = self._do_stop(mode, block_callbacks, checkpoint, on_safepoint, on_shutdown, stop_timeout)
if success: if success:
# block_callbacks is used during restart to avoid # block_callbacks is used during restart to avoid
# running start/stop callbacks in addition to restart ones # running start/stop callbacks in addition to restart ones
@@ -623,7 +635,7 @@ class Postgresql(object):
self.set_state('stop failed') self.set_state('stop failed')
return success return success
def _do_stop(self, mode, block_callbacks, checkpoint, on_safepoint, stop_timeout): def _do_stop(self, mode, block_callbacks, checkpoint, on_safepoint, on_shutdown, stop_timeout):
postmaster = self.is_running() postmaster = self.is_running()
if not postmaster: if not postmaster:
if on_safepoint: if on_safepoint:
@@ -650,6 +662,22 @@ class Postgresql(object):
postmaster.wait_for_user_backends_to_close() postmaster.wait_for_user_backends_to_close()
on_safepoint() on_safepoint()
if on_shutdown and mode in ('fast', 'smart'):
i = 0
# Wait for pg_controldata `Database cluster state:` to change to "shut down"
while postmaster.is_running():
data = self.controldata()
if data.get('Database cluster state', '') == 'shut down':
on_shutdown(int(self.latest_checkpoint_location()))
break
elif data.get('Database cluster state', '').startswith('shut down'): # shut down in recovery
break
elif stop_timeout and i >= stop_timeout:
stop_timeout = 0
break
time.sleep(STOP_POLLING_INTERVAL)
i += STOP_POLLING_INTERVAL
try: try:
postmaster.wait(timeout=stop_timeout) postmaster.wait(timeout=stop_timeout)
except TimeoutExpired: except TimeoutExpired:
@@ -684,7 +712,7 @@ class Postgresql(object):
while postmaster.is_running(): # Need a timeout here? while postmaster.is_running(): # Need a timeout here?
cur.execute("SELECT 1") cur.execute("SELECT 1")
time.sleep(STOP_POLLING_INTERVAL) time.sleep(STOP_POLLING_INTERVAL)
except psycopg2.Error: except psycopg.Error:
pass pass
def reload(self, block_callbacks=False): def reload(self, block_callbacks=False):
@@ -809,7 +837,7 @@ class Postgresql(object):
return None, None return None, None
@contextmanager @contextmanager
def get_replication_connection_cursor(self, host='localhost', port=5432, **kwargs): def get_replication_connection_cursor(self, host=None, port=5432, **kwargs):
conn_kwargs = self.config.replication.copy() conn_kwargs = self.config.replication.copy()
conn_kwargs.update(host=host, port=int(port) if port else None, user=conn_kwargs.pop('username'), conn_kwargs.update(host=host, port=int(port) if port else None, user=conn_kwargs.pop('username'),
connect_timeout=3, replication=1, options='-c statement_timeout=2000') connect_timeout=3, replication=1, options='-c statement_timeout=2000')
@@ -861,20 +889,22 @@ class Postgresql(object):
if change_role: if change_role:
self.__cb_pending = ACTION_NOOP self.__cb_pending = ACTION_NOOP
ret = True
if self.is_running(): if self.is_running():
if do_reload: if do_reload:
self.config.write_postgresql_conf() self.config.write_postgresql_conf()
if self.reload(block_callbacks=change_role) and change_role: ret = self.reload(block_callbacks=change_role)
if ret and change_role:
self.set_role(role) self.set_role(role)
else: else:
self.restart(block_callbacks=change_role, role=role) ret = self.restart(block_callbacks=change_role, role=role)
else: else:
self.start(timeout=timeout, block_callbacks=change_role, role=role) ret = self.start(timeout=timeout, block_callbacks=change_role, role=role) or None
if change_role: if change_role:
# TODO: postpone this until start completes, or maybe do even earlier # TODO: postpone this until start completes, or maybe do even earlier
self.call_nowait(ACTION_ON_ROLE_CHANGE) self.call_nowait(ACTION_ON_ROLE_CHANGE)
return True return ret
def _wait_promote(self, wait_seconds): def _wait_promote(self, wait_seconds):
for _ in polling_loop(wait_seconds): for _ in polling_loop(wait_seconds):
@@ -955,7 +985,7 @@ class Postgresql(object):
with self.connection().cursor() as cursor: with self.connection().cursor() as cursor:
cursor.execute(query) cursor.execute(query)
return cursor.fetchone()[0].isoformat(sep=' ') return cursor.fetchone()[0].isoformat(sep=' ')
except psycopg2.Error: except psycopg.Error:
return None return None
def last_operation(self): def last_operation(self):
@@ -1082,11 +1112,12 @@ class Postgresql(object):
" ORDER BY sync_state DESC, {0}_{1} DESC".format(sort_col, self.lsn_name, self.wal_name)): " ORDER BY sync_state DESC, {0}_{1} DESC".format(sort_col, self.lsn_name, self.wal_name)):
member = members.get(app_name) member = members.get(app_name)
if member and not member.tags.get('nosync', False): if member and not member.tags.get('nosync', False):
replica_list.append((member.name, sync_state, replica_lsn)) replica_list.append((member.name, sync_state, replica_lsn, bool(member.nofailover)))
max_lsn = max(replica_list, key=lambda x: x[2])[2] if len(replica_list) > 1 else int(str(self.last_operation())) max_lsn = max(replica_list, key=lambda x: x[2])[2] if len(replica_list) > 1 else int(str(self.last_operation()))
for app_name, sync_state, replica_lsn in replica_list: # Prefer members without nofailover tag. We are relying on the fact that sorts are guaranteed to be stable.
for app_name, sync_state, replica_lsn, _ in sorted(replica_list, key=lambda x: x[3]):
if sync_node_maxlag <= 0 or max_lsn - replica_lsn <= sync_node_maxlag: if sync_node_maxlag <= 0 or max_lsn - replica_lsn <= sync_node_maxlag:
candidates.append(app_name) candidates.append(app_name)
if sync_state == 'sync': if sync_state == 'sync':
+12 -12
View File
@@ -4,10 +4,12 @@ import shlex
import tempfile import tempfile
import time import time
from patroni.dcs import RemoteMember
from patroni.utils import deep_compare
from six import string_types from six import string_types
from ..dcs import RemoteMember
from ..psycopg import quote_ident, quote_literal
from ..utils import deep_compare
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -297,26 +299,24 @@ class Bootstrap(object):
if 'NOLOGIN' not in options and 'LOGIN' not in options: if 'NOLOGIN' not in options and 'LOGIN' not in options:
options.append('LOGIN') options.append('LOGIN')
params = [name]
if password: if password:
options.extend(['PASSWORD', '%s']) options.extend(['PASSWORD', quote_literal(password)])
params.extend([password, password])
sql = """DO $$ sql = """DO $$
BEGIN BEGIN
SET local synchronous_commit = 'local'; SET local synchronous_commit = 'local';
PERFORM * FROM pg_authid WHERE rolname = %s; PERFORM * FROM pg_catalog.pg_authid WHERE rolname = {0};
IF FOUND THEN IF FOUND THEN
ALTER ROLE "{0}" WITH {1}; ALTER ROLE {1} WITH {2};
ELSE ELSE
CREATE ROLE "{0}" WITH {1}; CREATE ROLE {1} WITH {2};
END IF; END IF;
END;$$""".format(name, ' '.join(options)) END;$$""".format(quote_literal(name), quote_ident(name, self._postgresql.connection()), ' '.join(options))
self._postgresql.query('SET log_statement TO none') self._postgresql.query('SET log_statement TO none')
self._postgresql.query('SET log_min_duration_statement TO -1') self._postgresql.query('SET log_min_duration_statement TO -1')
self._postgresql.query("SET log_min_error_statement TO 'log'") self._postgresql.query("SET log_min_error_statement TO 'log'")
try: try:
self._postgresql.query(sql, *params) self._postgresql.query(sql)
finally: finally:
self._postgresql.query('RESET log_min_error_statement') self._postgresql.query('RESET log_min_error_statement')
self._postgresql.query('RESET log_min_duration_statement') self._postgresql.query('RESET log_min_duration_statement')
@@ -342,8 +342,8 @@ END;$$""".format(name, ' '.join(options))
sql = """DO $$ sql = """DO $$
BEGIN BEGIN
SET local synchronous_commit = 'local'; SET local synchronous_commit = 'local';
GRANT EXECUTE ON function pg_catalog.{0} TO "{1}"; GRANT EXECUTE ON function pg_catalog.{0} TO {1};
END;$$""".format(f, rewind['username']) END;$$""".format(f, quote_ident(rewind['username'], self._postgresql.connection()))
postgresql.query(sql) postgresql.query(sql)
for name, value in (config.get('users') or {}).items(): for name, value in (config.get('users') or {}).items():
+29 -15
View File
@@ -12,6 +12,7 @@ from .validator import CaseInsensitiveDict, recovery_parameters,\
transform_postgresql_parameter_value, transform_recovery_parameter_value transform_postgresql_parameter_value, transform_recovery_parameter_value
from ..dcs import slot_name_from_member_name, RemoteMember from ..dcs import slot_name_from_member_name, RemoteMember
from ..exceptions import PatroniFatalException from ..exceptions import PatroniFatalException
from ..psycopg import quote_ident as _quote_ident
from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, \ from ..utils import compare_values, parse_bool, parse_int, split_host_port, uri, \
validate_directory, is_subpath validate_directory, is_subpath
@@ -23,7 +24,7 @@ PARAMETER_RE = re.compile(r'([a-z_]+)\s*=\s*')
def quote_ident(value): def quote_ident(value):
"""Very simplified version of quote_ident""" """Very simplified version of quote_ident"""
return value if SYNC_STANDBY_NAME_RE.match(value) else '"' + value + '"' return value if SYNC_STANDBY_NAME_RE.match(value) else _quote_ident(value)
def conninfo_uri_parse(dsn): def conninfo_uri_parse(dsn):
@@ -477,18 +478,20 @@ class ConfigHandler(object):
ret.setdefault('channel_binding', 'prefer') ret.setdefault('channel_binding', 'prefer')
if self._krbsrvname: if self._krbsrvname:
ret['krbsrvname'] = self._krbsrvname ret['krbsrvname'] = self._krbsrvname
if 'database' in ret: if 'dbname' in ret:
del ret['database'] del ret['dbname']
return ret return ret
def format_dsn(self, params, include_dbname=False): def format_dsn(self, params, include_dbname=False):
# A list of keywords that can be found in a conninfo string. Follows what is acceptable by libpq # A list of keywords that can be found in a conninfo string. Follows what is acceptable by libpq
keywords = ('dbname', 'user', 'passfile' if params.get('passfile') else 'password', 'host', 'port', keywords = ('dbname', 'user', 'passfile' if params.get('passfile') else 'password', 'host', 'port',
'sslmode', 'sslcompression', 'sslcert', 'sslkey', 'sslpassword', 'sslrootcert', 'sslcrl', 'sslmode', 'sslcompression', 'sslcert', 'sslkey', 'sslpassword', 'sslrootcert', 'sslcrl',
'application_name', 'krbsrvname', 'gssencmode', 'channel_binding') 'sslcrldir', 'application_name', 'krbsrvname', 'gssencmode', 'channel_binding',
'target_session_attrs')
if include_dbname: if include_dbname:
params = params.copy() params = params.copy()
params['dbname'] = params.get('database') or self._postgresql.database if 'dbname' not in params:
params['dbname'] = self._postgresql.database
# we are abusing information about the necessity of dbname # we are abusing information about the necessity of dbname
# dsn should contain passfile or password only if there is no dbname in it (it is used in recovery.conf) # dsn should contain passfile or password only if there is no dbname in it (it is used in recovery.conf)
skip = {'passfile', 'password'} skip = {'passfile', 'password'}
@@ -540,6 +543,12 @@ class ConfigHandler(object):
if use_slots and not (is_remote_master and member.no_replication_slot): if use_slots and not (is_remote_master and member.no_replication_slot):
primary_slot_name = member.primary_slot_name if is_remote_master else self._postgresql.name primary_slot_name = member.primary_slot_name if is_remote_master else self._postgresql.name
recovery_params['primary_slot_name'] = slot_name_from_member_name(primary_slot_name) recovery_params['primary_slot_name'] = slot_name_from_member_name(primary_slot_name)
# We are a standby leader and are using a replication slot. Make sure we connect to
# the leader of the main cluster (in case more than one host is specified in the
# connstr) by adding 'target_session_attrs=read-write' to primary_conninfo.
if is_remote_master and 'target_sesions_attrs' not in primary_conninfo and\
self._postgresql.major_version >= 100000:
primary_conninfo['target_session_attrs'] = 'read-write'
recovery_params['primary_conninfo'] = primary_conninfo recovery_params['primary_conninfo'] = primary_conninfo
# standby_cluster config might have different parameters, we want to override them # standby_cluster config might have different parameters, we want to override them
@@ -616,19 +625,19 @@ class ConfigHandler(object):
def _check_passfile(self, passfile, wanted_primary_conninfo): def _check_passfile(self, passfile, wanted_primary_conninfo):
# If there is a passfile in the primary_conninfo try to figure out that # If there is a passfile in the primary_conninfo try to figure out that
# the passfile contains the line allowing connection to the given node. # the passfile contains the line(s) allowing connection to the given node.
# We assume that the passfile was created by Patroni and therefore doing # We assume that the passfile was created by Patroni and therefore doing
# the full match and not covering cases when host, port or user are set to '*' # the full match and not covering cases when host, port or user are set to '*'
passfile_mtime = mtime(passfile) passfile_mtime = mtime(passfile)
if passfile_mtime: if passfile_mtime:
try: try:
with open(passfile) as f: with open(passfile) as f:
wanted_line = self._pgpass_line(wanted_primary_conninfo).strip() wanted_lines = self._pgpass_line(wanted_primary_conninfo).splitlines()
for raw_line in f: file_lines = f.read().splitlines()
if raw_line.strip() == wanted_line: if set(wanted_lines) == set(file_lines):
self._passfile = passfile self._passfile = passfile
self._passfile_mtime = passfile_mtime self._passfile_mtime = passfile_mtime
return True return True
except Exception: except Exception:
logger.info('Failed to read %s', passfile) logger.info('Failed to read %s', passfile)
return False return False
@@ -743,7 +752,12 @@ class ConfigHandler(object):
return re.sub(r'([:\\])', r'\\\1', str(value)) return re.sub(r'([:\\])', r'\\\1', str(value))
record = {n: escape(record.get(n) or '*') for n in ('host', 'port', 'user', 'password')} record = {n: escape(record.get(n) or '*') for n in ('host', 'port', 'user', 'password')}
return '{host}:{port}:*:{user}:{password}'.format(**record) # 'host' could be several comma-separated hostnames, in this case
# we need to write on pgpass line per host
line = ''
for hostname in record.get('host').split(','):
line += hostname + ':{port}:*:{user}:{password}'.format(**record) + '\n'
return line.rstrip()
def write_pgpass(self, record): def write_pgpass(self, record):
line = self._pgpass_line(record) line = self._pgpass_line(record)
@@ -834,7 +848,7 @@ class ConfigHandler(object):
# this exercise is improving cross version compatibility and user must set the correct parameter in the config. # this exercise is improving cross version compatibility and user must set the correct parameter in the config.
if self._postgresql.major_version >= 130000: if self._postgresql.major_version >= 130000:
wal_keep_segments = parameters.pop('wal_keep_segments', self.CMDLINE_OPTIONS['wal_keep_segments'][0]) wal_keep_segments = parameters.pop('wal_keep_segments', self.CMDLINE_OPTIONS['wal_keep_segments'][0])
parameters.setdefault('wal_keep_size', str(wal_keep_segments * 16) + 'MB') parameters.setdefault('wal_keep_size', str(int(wal_keep_segments) * 16) + 'MB')
elif self._postgresql.major_version: elif self._postgresql.major_version:
wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB') wal_keep_size = parse_int(parameters.pop('wal_keep_size', self.CMDLINE_OPTIONS['wal_keep_size'][0]), 'MB')
parameters.setdefault('wal_keep_segments', int((wal_keep_size + 8) / 16)) parameters.setdefault('wal_keep_segments', int((wal_keep_size + 8) / 16))
@@ -870,7 +884,7 @@ class ConfigHandler(object):
ret['user'] = self._superuser['username'] ret['user'] = self._superuser['username']
del ret['username'] del ret['username']
# ensure certain Patroni configurations are available # ensure certain Patroni configurations are available
ret.update({'database': self._postgresql.database, ret.update({'dbname': self._postgresql.database,
'fallback_application_name': 'Patroni', 'fallback_application_name': 'Patroni',
'connect_timeout': 3, 'connect_timeout': 3,
'options': '-c statement_timeout=2000'}) 'options': '-c statement_timeout=2000'})
+4 -3
View File
@@ -1,9 +1,10 @@
import logging import logging
import psycopg2
from contextlib import contextmanager from contextlib import contextmanager
from threading import Lock from threading import Lock
from .. import psycopg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,7 +21,7 @@ class Connection(object):
def get(self): def get(self):
with self._lock: with self._lock:
if not self._connection or self._connection.closed != 0: if not self._connection or self._connection.closed != 0:
self._connection = psycopg2.connect(**self._conn_kwargs) self._connection = psycopg.connect(**self._conn_kwargs)
self._connection.autocommit = True self._connection.autocommit = True
self.server_version = self._connection.server_version self.server_version = self._connection.server_version
return self._connection return self._connection
@@ -40,7 +41,7 @@ class Connection(object):
@contextmanager @contextmanager
def get_connection_cursor(**kwargs): def get_connection_cursor(**kwargs):
conn = psycopg2.connect(**kwargs) conn = psycopg.connect(**kwargs)
conn.autocommit = True conn.autocommit = True
with conn.cursor() as cur: with conn.cursor() as cur:
yield cur yield cur
+42 -18
View File
@@ -65,6 +65,20 @@ class Rewind(object):
except Exception: except Exception:
return logger.exception('Exception when working with leader') return logger.exception('Exception when working with leader')
@staticmethod
def check_leader_has_run_checkpoint(conn_kwargs):
try:
with get_connection_cursor(connect_timeout=3, options='-c statement_timeout=2000', **conn_kwargs) as cur:
cur.execute("SELECT NOT pg_catalog.pg_is_in_recovery()" +
" AND ('x' || pg_catalog.substr(pg_catalog.pg_walfile_name(" +
" pg_catalog.pg_current_wal_lsn()), 1, 8))::bit(32)::int = timeline_id" +
" FROM pg_catalog.pg_control_checkpoint()")
if not cur.fetchone()[0]:
return 'leader has not run a checkpoint yet'
except Exception:
logger.exception('Exception when working with leader')
return 'not accessible or not healty'
def _get_checkpoint_end(self, timeline, lsn): def _get_checkpoint_end(self, timeline, lsn):
"""The checkpoint record size in WAL depends on postgres major version and platform (memory alignment). """The checkpoint record size in WAL depends on postgres major version and platform (memory alignment).
Hence, the only reliable way to figure out where it ends, read the record from file with the help of pg_waldump Hence, the only reliable way to figure out where it ends, read the record from file with the help of pg_waldump
@@ -152,8 +166,12 @@ class Rewind(object):
def _conn_kwargs(self, member, auth): def _conn_kwargs(self, member, auth):
ret = member.conn_kwargs(auth) ret = member.conn_kwargs(auth)
if not ret.get('database'): if not ret.get('dbname'):
ret['database'] = self._postgresql.database ret['dbname'] = self._postgresql.database
# Add target_session_attrs in case more than one hostname is specified
# (libpq client-side failover) making sure we hit the primary
if 'target_session_attrs' not in ret and self._postgresql.major_version >= 100000:
ret['target_session_attrs'] = 'read-write'
return ret return ret
def _check_timeline_and_lsn(self, leader): def _check_timeline_and_lsn(self, leader):
@@ -161,11 +179,11 @@ class Rewind(object):
if local_timeline is None or local_lsn is None: if local_timeline is None or local_lsn is None:
return return
if isinstance(leader, Leader): if isinstance(leader, Leader) and leader.member.data.get('role') != 'master':
if leader.member.data.get('role') != 'master': return
return
# standby cluster if not self.check_leader_is_not_in_recovery(
elif not self.check_leader_is_not_in_recovery(self._conn_kwargs(leader, self._postgresql.config.replication)): self._conn_kwargs(leader, self._postgresql.config.replication)):
return return
history = need_rewind = None history = need_rewind = None
@@ -179,7 +197,7 @@ class Rewind(object):
elif local_timeline == master_timeline: elif local_timeline == master_timeline:
need_rewind = False need_rewind = False
elif master_timeline > 1: elif master_timeline > 1:
cur.execute('TIMELINE_HISTORY %s', (master_timeline,)) cur.execute('TIMELINE_HISTORY {0}'.format(master_timeline))
history = cur.fetchone()[1] history = cur.fetchone()[1]
if not isinstance(history, six.string_types): if not isinstance(history, six.string_types):
history = bytes(history).decode('utf-8') history = bytes(history).decode('utf-8')
@@ -202,7 +220,10 @@ class Rewind(object):
need_rewind = switchpoint != self._get_checkpoint_end(local_timeline, local_lsn) need_rewind = switchpoint != self._get_checkpoint_end(local_timeline, local_lsn)
break break
elif parent_timeline > local_timeline: elif parent_timeline > local_timeline:
need_rewind = True
break break
else:
need_rewind = True
self._log_master_history(history, i) self._log_master_history(history, i)
self._state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED self._state = need_rewind and REWIND_STATUS.NEED or REWIND_STATUS.NOT_NEED
@@ -230,16 +251,14 @@ class Rewind(object):
with self._checkpoint_task_lock: with self._checkpoint_task_lock:
if self._checkpoint_task: if self._checkpoint_task:
with self._checkpoint_task: with self._checkpoint_task:
if self._checkpoint_task.result: if self._checkpoint_task.result is not None:
self._state = REWIND_STATUS.CHECKPOINT self._state = REWIND_STATUS.CHECKPOINT
if self._checkpoint_task.result is not False: self._checkpoint_task = None
return elif self._postgresql.get_master_timeline() == self._postgresql.pg_control_timeline():
self._state = REWIND_STATUS.CHECKPOINT
else: else:
self._checkpoint_task = CriticalTask() self._checkpoint_task = CriticalTask()
return Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start() Thread(target=self.__checkpoint, args=(self._checkpoint_task, wakeup)).start()
if self._postgresql.get_master_timeline() == self._postgresql.pg_control_timeline():
self._state = REWIND_STATUS.CHECKPOINT
def checkpoint_after_promote(self): def checkpoint_after_promote(self):
return self._state == REWIND_STATUS.CHECKPOINT return self._state == REWIND_STATUS.CHECKPOINT
@@ -333,9 +352,14 @@ class Rewind(object):
# running a checkpoint or # running a checkpoint or
# waiting until Patroni on the master will expose checkpoint_after_promote=True # waiting until Patroni on the master will expose checkpoint_after_promote=True
checkpoint_status = leader.checkpoint_after_promote if isinstance(leader, Leader) else None checkpoint_status = leader.checkpoint_after_promote if isinstance(leader, Leader) else None
if checkpoint_status is None: # master still runs the old Patroni if checkpoint_status is None: # we are the standby-cluster leader or master still runs the old Patroni
leader_status = self._postgresql.checkpoint(self._conn_kwargs(leader, self._postgresql.config.superuser)) # superuser credentials match rewind_credentials if the latter are not provided or we run 10 or older
if leader_status: if self._postgresql.config.superuser == self._postgresql.config.rewind_credentials:
leader_status = self._postgresql.checkpoint(
self._conn_kwargs(leader, self._postgresql.config.superuser))
else: # we run 11+ and have a dedicated pg_rewind user
leader_status = self.check_leader_has_run_checkpoint(r)
if leader_status: # we tried to run/check for a checkpoint on the remote leader, but it failed
return logger.warning('Can not use %s for rewind: %s', leader.name, leader_status) return logger.warning('Can not use %s for rewind: %s', leader.name, leader_status)
elif not checkpoint_status: elif not checkpoint_status:
return logger.info('Waiting for checkpoint on %s before rewind', leader.name) return logger.info('Waiting for checkpoint on %s before rewind', leader.name)
+9 -8
View File
@@ -5,10 +5,10 @@ import shutil
from collections import defaultdict from collections import defaultdict
from contextlib import contextmanager from contextlib import contextmanager
from psycopg2.errors import UndefinedFile
from .connection import get_connection_cursor from .connection import get_connection_cursor
from .misc import format_lsn from .misc import format_lsn
from ..psycopg import OperationalError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -82,8 +82,9 @@ class SlotsHandler(object):
replication_slots = {} replication_slots = {}
extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\ extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\
if self._postgresql.major_version >= 100000 else "" if self._postgresql.major_version >= 100000 else ""
skip_temp_slots = ' WHERE NOT temporary' if self._postgresql.major_version >= 100000 else ''
cursor = self._query('SELECT slot_name, slot_type, plugin, database, datoid' cursor = self._query('SELECT slot_name, slot_type, plugin, database, datoid'
'{0} FROM pg_catalog.pg_replication_slots'.format(extra)) '{0} FROM pg_catalog.pg_replication_slots{1}'.format(extra, skip_temp_slots))
for r in cursor: for r in cursor:
value = {'type': r[1]} value = {'type': r[1]}
if r[1] == 'logical': if r[1] == 'logical':
@@ -142,9 +143,9 @@ class SlotsHandler(object):
self._schedule_load_slots = True self._schedule_load_slots = True
@contextmanager @contextmanager
def _get_local_connection_cursor(self, database): def _get_local_connection_cursor(self, **kwargs):
conn_kwargs = self._postgresql.config.local_connect_kwargs conn_kwargs = self._postgresql.config.local_connect_kwargs
conn_kwargs['database'] = database conn_kwargs.update(kwargs)
with get_connection_cursor(**conn_kwargs) as cur: with get_connection_cursor(**conn_kwargs) as cur:
yield cur yield cur
@@ -161,7 +162,7 @@ class SlotsHandler(object):
# Create new logical slots # Create new logical slots
for database, values in logical_slots.items(): for database, values in logical_slots.items():
with self._get_local_connection_cursor(database) as cur: with self._get_local_connection_cursor(dbname=database) as cur:
for name, value in values.items(): for name, value in values.items():
try: try:
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" + cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
@@ -193,14 +194,14 @@ class SlotsHandler(object):
# Advance logical slots # Advance logical slots
for database, values in advance_slots.items(): for database, values in advance_slots.items():
with self._get_local_connection_cursor(database) as cur: with self._get_local_connection_cursor(dbname=database, options='-c statement_timeout=0') as cur:
for name, value in values.items(): for name, value in values.items():
try: try:
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)", cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)",
(name, format_lsn(int(cluster.slots[name])))) (name, format_lsn(int(cluster.slots[name]))))
except Exception as e: except Exception as e:
logger.error("Failed to advance logical replication slot '%s': %r", name, e) logger.error("Failed to advance logical replication slot '%s': %r", name, e)
if isinstance(e, UndefinedFile): if isinstance(e, OperationalError) and e.diag.sqlstate == '58P01': # WAL file is gone
create_slots.append(name) create_slots.append(name)
self._schedule_load_slots = True self._schedule_load_slots = True
return create_slots return create_slots
@@ -235,7 +236,7 @@ class SlotsHandler(object):
@contextmanager @contextmanager
def _get_leader_connection_cursor(self, leader): def _get_leader_connection_cursor(self, leader):
conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials) conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
conn_kwargs['database'] = self._postgresql.database conn_kwargs['dbname'] = self._postgresql.database
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur: with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
yield cur yield cur
+6 -6
View File
@@ -151,7 +151,7 @@ parameters = CaseInsensitiveDict({
Integer(90600, None, 30, 86400, 's') Integer(90600, None, 30, 86400, 's')
), ),
'checkpoint_warning': Integer(90300, None, 0, 2147483647, 's'), 'checkpoint_warning': Integer(90300, None, 0, 2147483647, 's'),
'client_connection_check_interval': Integer(140000, None, '0', '2147483647', 'ms'), 'client_connection_check_interval': Integer(140000, None, 0, 2147483647, 'ms'),
'client_encoding': String(90300, None), 'client_encoding': String(90300, None),
'client_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2', 'client_min_messages': Enum(90300, None, ('debug5', 'debug4', 'debug3', 'debug2',
'debug1', 'log', 'notice', 'warning', 'error')), 'debug1', 'log', 'notice', 'warning', 'error')),
@@ -234,10 +234,10 @@ parameters = CaseInsensitiveDict({
'hot_standby': Bool(90300, None), 'hot_standby': Bool(90300, None),
'hot_standby_feedback': Bool(90300, None), 'hot_standby_feedback': Bool(90300, None),
'huge_pages': EnumBool(90400, None, ('try',)), 'huge_pages': EnumBool(90400, None, ('try',)),
'huge_page_size': Integer(140000, None, '0', '2147483647', 'kB'), 'huge_page_size': Integer(140000, None, 0, 2147483647, 'kB'),
'ident_file': String(90300, None), 'ident_file': String(90300, None),
'idle_in_transaction_session_timeout': Integer(90600, None, 0, 2147483647, 'ms'), 'idle_in_transaction_session_timeout': Integer(90600, None, 0, 2147483647, 'ms'),
'idle_session_timeout': Integer(140000, None, '0', '2147483647', 'ms'), 'idle_session_timeout': Integer(140000, None, 0, 2147483647, 'ms'),
'ignore_checksum_failure': Bool(90300, None), 'ignore_checksum_failure': Bool(90300, None),
'ignore_invalid_pages': Bool(130000, None), 'ignore_invalid_pages': Bool(130000, None),
'ignore_system_indexes': Bool(90300, None), 'ignore_system_indexes': Bool(90300, None),
@@ -344,7 +344,7 @@ parameters = CaseInsensitiveDict({
Integer(90400, 90600, 1, 8388607, None), Integer(90400, 90600, 1, 8388607, None),
Integer(90600, None, 0, 262143, None) Integer(90600, None, 0, 262143, None)
), ),
'min_dynamic_shared_memory': Integer(140000, None, '0', '2147483647', 'MB'), 'min_dynamic_shared_memory': Integer(140000, None, 0, 2147483647, 'MB'),
'min_parallel_index_scan_size': Integer(100000, None, 0, 715827882, '8kB'), 'min_parallel_index_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
'min_parallel_relation_size': Integer(90600, 100000, 0, 715827882, '8kB'), 'min_parallel_relation_size': Integer(90600, 100000, 0, 715827882, '8kB'),
'min_parallel_table_scan_size': Integer(100000, None, 0, 715827882, '8kB'), 'min_parallel_table_scan_size': Integer(100000, None, 0, 715827882, '8kB'),
@@ -456,10 +456,10 @@ parameters = CaseInsensitiveDict({
'vacuum_cost_page_hit': Integer(90300, None, 0, 10000, None), 'vacuum_cost_page_hit': Integer(90300, None, 0, 10000, None),
'vacuum_cost_page_miss': Integer(90300, None, 0, 10000, None), 'vacuum_cost_page_miss': Integer(90300, None, 0, 10000, None),
'vacuum_defer_cleanup_age': Integer(90300, None, 0, 1000000, None), 'vacuum_defer_cleanup_age': Integer(90300, None, 0, 1000000, None),
'vacuum_failsafe_age': Integer(140000, None, '0', '2100000000', None), 'vacuum_failsafe_age': Integer(140000, None, 0, 2100000000, None),
'vacuum_freeze_min_age': Integer(90300, None, 0, 1000000000, None), 'vacuum_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
'vacuum_freeze_table_age': Integer(90300, None, 0, 2000000000, None), 'vacuum_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
'vacuum_multixact_failsafe_age': Integer(140000, None, '0', '2100000000', None), 'vacuum_multixact_failsafe_age': Integer(140000, None, 0, 2100000000, None),
'vacuum_multixact_freeze_min_age': Integer(90300, None, 0, 1000000000, None), 'vacuum_multixact_freeze_min_age': Integer(90300, None, 0, 1000000000, None),
'vacuum_multixact_freeze_table_age': Integer(90300, None, 0, 2000000000, None), 'vacuum_multixact_freeze_table_age': Integer(90300, None, 0, 2000000000, None),
'wal_buffers': Integer(90300, None, -1, 262143, '8kB'), 'wal_buffers': Integer(90300, None, -1, 262143, '8kB'),
+40
View File
@@ -0,0 +1,40 @@
__all__ = ['connect', 'quote_ident', 'quote_literal', 'DatabaseError', 'Error', 'OperationalError', 'ProgrammingError']
_legacy = False
try:
from psycopg2 import __version__
from . import MIN_PSYCOPG2, parse_version
if parse_version(__version__) < MIN_PSYCOPG2:
raise ImportError
from psycopg2 import connect, Error, DatabaseError, OperationalError, ProgrammingError
from psycopg2.extensions import adapt
try:
from psycopg2.extensions import quote_ident as _quote_ident
except ImportError:
_legacy = True
def quote_literal(value, conn=None):
value = adapt(value)
if conn:
value.prepare(conn)
return value.getquoted().decode('utf-8')
except ImportError:
from psycopg import connect as _connect, sql, Error, DatabaseError, OperationalError, ProgrammingError
def connect(*args, **kwargs):
ret = _connect(*args, **kwargs)
ret.server_version = ret.pgconn.server_version # compatibility with psycopg2
return ret
def _quote_ident(value, conn):
return sql.Identifier(value).as_string(conn)
def quote_literal(value, conn=None):
return sql.Literal(value).as_string(conn)
def quote_ident(value, conn=None):
if _legacy or conn is None:
return '"{0}"'.format(value.replace('"', '""'))
return _quote_ident(value, conn)
+3
View File
@@ -34,6 +34,9 @@ class PatroniRequest(object):
if self._apply_ssl_file_param(config, 'cert'): if self._apply_ssl_file_param(config, 'cert'):
self._apply_ssl_file_param(config, 'key') self._apply_ssl_file_param(config, 'key')
password = self._get_cfg_value(config, 'keyfile_password')
self._apply_pool_param('key_password', password)
else: else:
self._pool.connection_pool_kw.pop('key_file', None) self._pool.connection_pool_kw.pop('key_file', None)
+4 -3
View File
@@ -27,13 +27,14 @@ import argparse
import csv import csv
import logging import logging
import os import os
import psycopg2
import subprocess import subprocess
import sys import sys
import time import time
from collections import namedtuple from collections import namedtuple
from .. import psycopg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RETRY_SLEEP_INTERVAL = 1 RETRY_SLEEP_INTERVAL = 1
@@ -215,7 +216,7 @@ class WALERestore(object):
if self.master_connection: if self.master_connection:
try: try:
# get the difference in bytes between the current WAL location and the backup start offset # get the difference in bytes between the current WAL location and the backup start offset
with psycopg2.connect(self.master_connection) as con: with psycopg.connect(self.master_connection) as con:
if con.server_version >= 100000: if con.server_version >= 100000:
wal_name = 'wal' wal_name = 'wal'
lsn_name = 'lsn' lsn_name = 'lsn'
@@ -233,7 +234,7 @@ class WALERestore(object):
(backup_start_lsn, backup_start_lsn, backup_start_lsn)) (backup_start_lsn, backup_start_lsn, backup_start_lsn))
diff_in_bytes = int(cur.fetchone()[0]) diff_in_bytes = int(cur.fetchone()[0])
except psycopg2.Error: except psycopg.Error:
logger.exception('could not determine difference with the master location') logger.exception('could not determine difference with the master location')
if attempts_no < self.retries: # retry in case of a temporarily connection issue if attempts_no < self.retries: # retry in case of a temporarily connection issue
attempts_no = attempts_no + 1 attempts_no = attempts_no + 1
+1 -1
View File
@@ -1 +1 @@
__version__ = '2.1.1' __version__ = '2.1.3'
+1 -1
View File
@@ -91,7 +91,7 @@ bootstrap:
# Some additional users users which needs to be created after initializing new cluster # Some additional users users which needs to be created after initializing new cluster
users: users:
admin: admin:
password: admin password: admin%
options: options:
- createrole - createrole
- createdb - createdb
+1 -1
View File
@@ -85,7 +85,7 @@ bootstrap:
# Some additional users users which needs to be created after initializing new cluster # Some additional users users which needs to be created after initializing new cluster
users: users:
admin: admin:
password: admin password: admin%
options: options:
- createrole - createrole
- createdb - createdb
+1 -1
View File
@@ -82,7 +82,7 @@ bootstrap:
# Some additional users users which needs to be created after initializing new cluster # Some additional users users which needs to be created after initializing new cluster
users: users:
admin: admin:
password: admin password: admin%
options: options:
- createrole - createrole
- createdb - createdb
+24 -66
View File
@@ -5,6 +5,7 @@
""" """
import inspect import inspect
import logging
import os import os
import sys import sys
@@ -26,7 +27,6 @@ EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'etcd3': ['python-et
'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'], 'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']} 'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
COVERAGE_XML = True COVERAGE_XML = True
COVERAGE_HTML = False
# Add here all kinds of additional classifiers as defined under # Add here all kinds of additional classifiers as defined under
# https://pypi.python.org/pypi?%3Aaction=list_classifiers # https://pypi.python.org/pypi?%3Aaction=list_classifiers
@@ -49,29 +49,29 @@ CLASSIFIERS = [
'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: CPython',
] ]
CONSOLE_SCRIPTS = ['patroni = patroni:main', CONSOLE_SCRIPTS = ['patroni = patroni.__main__:main',
'patronictl = patroni.ctl:ctl', 'patronictl = patroni.ctl:ctl',
'patroni_raft_controller = patroni.raft_controller:main', 'patroni_raft_controller = patroni.raft_controller:main',
"patroni_wale_restore = patroni.scripts.wale_restore:main", "patroni_wale_restore = patroni.scripts.wale_restore:main",
"patroni_aws = patroni.scripts.aws:main"] "patroni_aws = patroni.scripts.aws:main"]
class Flake8(Command): class _Command(Command):
user_options = [] user_options = []
def initialize_options(self): def initialize_options(self):
from flake8.main import application pass
self.flake8 = application.Application()
self.flake8.initialize([])
def finalize_options(self): def finalize_options(self):
pass pass
class Flake8(_Command):
def package_files(self): def package_files(self):
seen_package_directories = () seen_package_directories = ()
directories = self.distribution.package_dir or {} directories = self.distribution.package_dir or {}
@@ -93,68 +93,31 @@ class Flake8(Command):
return [package for package in self.package_files()] + ['tests', 'setup.py'] return [package for package in self.package_files()] + ['tests', 'setup.py']
def run(self): def run(self):
self.flake8.run_checks(self.targets()) from flake8.main import application
self.flake8.formatter.start()
self.flake8.report_errors() logging.getLogger().setLevel(logging.ERROR)
self.flake8.report_statistics() flake8 = application.Application()
self.flake8.report_benchmarks() flake8.run(self.targets())
self.flake8.formatter.stop() flake8.exit()
try:
self.flake8.exit()
except SystemExit as e:
# Cause system exit only if exit code is not zero (terminates
# other possibly remaining/pending setuptools commands).
if e.code:
raise
class PyTest(Command): class PyTest(_Command):
user_options = [('cov=', None, 'Run coverage'), ('cov-xml=', None, 'Generate junit xml report'), def run(self):
('cov-html=', None, 'Generate junit html report')]
def initialize_options(self):
self.cov = []
self.cov_xml = False
self.cov_html = False
def finalize_options(self):
if self.cov_xml or self.cov_html:
self.cov = ['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing']
if self.cov_xml:
self.cov.extend(['--cov-report', 'xml'])
if self.cov_html:
self.cov.extend(['--cov-report', 'html'])
def run_tests(self):
try: try:
import pytest import pytest
except Exception: except Exception:
raise RuntimeError('py.test is not installed, run: pip install pytest') raise RuntimeError('py.test is not installed, run: pip install pytest')
import logging logging.getLogger().setLevel(logging.WARNING)
silence = logging.WARNING
logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=os.getenv('LOGLEVEL', silence))
args = ['--verbose', 'tests', '--doctest-modules', MAIN_PACKAGE] +\ args = ['--verbose', 'tests', '--doctest-modules', MAIN_PACKAGE] +\
['-s' if logging.getLogger().getEffectiveLevel() < silence else '--capture=fd'] ['-s' if logging.getLogger().getEffectiveLevel() < logging.WARNING else '--capture=fd'] +\
if self.cov: ['--cov', MAIN_PACKAGE, '--cov-report', 'term-missing', '--cov-report', 'xml']
args += self.cov
errno = pytest.main(args=args) errno = pytest.main(args=args)
sys.exit(errno) sys.exit(errno)
def run(self):
from pkg_resources import evaluate_marker
requirements = set(self.distribution.install_requires + ['mock>=2.0.0', 'pytest-cov', 'pytest'])
for k, v in self.distribution.extras_require.items():
if not k.startswith(':') or evaluate_marker(k[1:]):
requirements.update(v)
self.distribution.fetch_build_eggs(list(requirements))
self.run_tests()
def read(fname): def read(fname):
with open(os.path.join(__location__, fname)) as fd: with open(os.path.join(__location__, fname)) as fd:
@@ -162,6 +125,8 @@ def read(fname):
def setup_package(version): def setup_package(version):
logging.basicConfig(format='%(message)s', level=os.getenv('LOGLEVEL', logging.WARNING))
# Assemble additional setup commands # Assemble additional setup commands
cmdclass = {'test': PyTest, 'flake8': Flake8} cmdclass = {'test': PyTest, 'flake8': Flake8}
@@ -184,12 +149,6 @@ def setup_package(version):
if not extra: if not extra:
install_requires.append(r) install_requires.append(r)
command_options = {'test': {}}
if COVERAGE_XML:
command_options['test']['cov_xml'] = 'setup.py', True
if COVERAGE_HTML:
command_options['test']['cov_html'] = 'setup.py', True
setup( setup(
name=NAME, name=NAME,
version=version, version=version,
@@ -206,9 +165,7 @@ def setup_package(version):
python_requires='>=2.7', python_requires='>=2.7',
install_requires=install_requires, install_requires=install_requires,
extras_require=EXTRAS_REQUIRE, extras_require=EXTRAS_REQUIRE,
setup_requires='flake8',
cmdclass=cmdclass, cmdclass=cmdclass,
command_options=command_options,
entry_points={'console_scripts': CONSOLE_SCRIPTS}, entry_points={'console_scripts': CONSOLE_SCRIPTS},
) )
@@ -216,13 +173,14 @@ def setup_package(version):
if __name__ == '__main__': if __name__ == '__main__':
old_modules = sys.modules.copy() old_modules = sys.modules.copy()
try: try:
from patroni import check_psycopg2, fatal, __version__ from patroni import check_psycopg, fatal
from patroni.version import __version__
finally: finally:
sys.modules.clear() sys.modules.clear()
sys.modules.update(old_modules) sys.modules.update(old_modules)
if sys.version_info < (2, 7, 0): if sys.version_info < (2, 7, 0):
fatal('Patroni needs to be run with Python 2.7+') fatal('Patroni needs to be run with Python 2.7+')
check_psycopg2() check_psycopg()
setup_package(__version__) setup_package(__version__)
+8 -6
View File
@@ -5,9 +5,10 @@ import unittest
from mock import Mock, patch from mock import Mock, patch
import psycopg2
import urllib3 import urllib3
import patroni.psycopg as psycopg
from patroni.dcs import Leader, Member from patroni.dcs import Leader, Member
from patroni.postgresql import Postgresql from patroni.postgresql import Postgresql
from patroni.postgresql.config import ConfigHandler from patroni.postgresql.config import ConfigHandler
@@ -85,9 +86,9 @@ class MockCursor(object):
def execute(self, sql, *params): def execute(self, sql, *params):
if sql.startswith('blabla'): if sql.startswith('blabla'):
raise psycopg2.ProgrammingError() raise psycopg.ProgrammingError()
elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'): elif sql == 'CHECKPOINT' or sql.startswith('SELECT pg_catalog.pg_create_'):
raise psycopg2.OperationalError() raise psycopg.OperationalError()
elif sql.startswith('RetryFailedError'): elif sql.startswith('RetryFailedError'):
raise RetryFailedError('retry') raise RetryFailedError('retry')
elif sql.startswith('SELECT catalog_xmin'): elif sql.startswith('SELECT catalog_xmin'):
@@ -162,7 +163,7 @@ class MockConnect(object):
pass pass
def psycopg2_connect(*args, **kwargs): def psycopg_connect(*args, **kwargs):
return MockConnect() return MockConnect()
@@ -176,7 +177,7 @@ class PostgresInit(unittest.TestCase):
'force_parallel_mode': '1', 'constraint_exclusion': '', 'force_parallel_mode': '1', 'constraint_exclusion': '',
'max_stack_depth': 'Z', 'vacuum_cost_limit': -1, 'vacuum_cost_delay': 200} 'max_stack_depth': 'Z', 'vacuum_cost_limit': -1, 'vacuum_cost_delay': 200}
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
@patch('patroni.postgresql.CallbackExecutor', Mock()) @patch('patroni.postgresql.CallbackExecutor', Mock())
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock()) @patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
@patch.object(ConfigHandler, 'replace_pg_hba', Mock()) @patch.object(ConfigHandler, 'replace_pg_hba', Mock())
@@ -189,7 +190,8 @@ class PostgresInit(unittest.TestCase):
'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'), 'krbsrvname': 'postgres', 'pgpass': os.path.join(data_dir, 'pgpass0'),
'listen': '127.0.0.2, 127.0.0.3:5432', 'connect_address': '127.0.0.2:5432', 'listen': '127.0.0.2, 127.0.0.3:5432', 'connect_address': '127.0.0.2:5432',
'authentication': {'superuser': {'username': 'foo', 'password': 'test'}, 'authentication': {'superuser': {'username': 'foo', 'password': 'test'},
'replication': {'username': '', 'password': 'rep-pass'}}, 'replication': {'username': '', 'password': 'rep-pass'},
'rewind': {'username': 'rewind', 'password': 'test'}},
'remove_data_directory_on_rewind_failure': True, 'remove_data_directory_on_rewind_failure': True,
'use_pg_rewind': True, 'pg_ctl_timeout': 'bla', 'use_pg_rewind': True, 'pg_ctl_timeout': 'bla',
'parameters': self._PARAMETERS, 'parameters': self._PARAMETERS,
+9 -7
View File
@@ -1,9 +1,10 @@
import datetime import datetime
import json import json
import psycopg2
import unittest import unittest
import socket import socket
import patroni.psycopg as psycopg
from mock import Mock, PropertyMock, patch from mock import Mock, PropertyMock, patch
from patroni.api import RestApiHandler, RestApiServer from patroni.api import RestApiHandler, RestApiServer
from patroni.dcs import ClusterConfig, Member from patroni.dcs import ClusterConfig, Member
@@ -11,7 +12,7 @@ from patroni.ha import _MemberStatus
from patroni.utils import tzutc from patroni.utils import tzutc
from six import BytesIO as IO from six import BytesIO as IO
from six.moves import BaseHTTPServer from six.moves import BaseHTTPServer
from . import psycopg2_connect, MockCursor from . import psycopg_connect, MockCursor
from .test_ha import get_cluster_initialized_without_leader from .test_ha import get_cluster_initialized_without_leader
@@ -35,7 +36,7 @@ class MockPostgresql(object):
@staticmethod @staticmethod
def connection(): def connection():
return psycopg2_connect() return psycopg_connect()
@staticmethod @staticmethod
def postmaster_start_time(): def postmaster_start_time():
@@ -77,7 +78,7 @@ class MockHa(object):
@staticmethod @staticmethod
def fetch_nodes_statuses(members): def fetch_nodes_statuses(members):
return [_MemberStatus(None, True, None, 0, None, {}, False)] return [_MemberStatus(None, True, None, 0, 0, None, {}, False)]
@staticmethod @staticmethod
def schedule_future_restart(data): def schedule_future_restart(data):
@@ -435,9 +436,9 @@ class TestRestApiHandler(unittest.TestCase):
@patch('time.sleep', Mock()) @patch('time.sleep', Mock())
def test_RestApiServer_query(self): def test_RestApiServer_query(self):
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)): with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni')) self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg2.OperationalError)): with patch.object(MockPostgresql, 'connection', Mock(side_effect=psycopg.OperationalError)):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni')) self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /patroni'))
@patch('time.sleep', Mock()) @patch('time.sleep', Mock())
@@ -548,7 +549,8 @@ class TestRestApiServer(unittest.TestCase):
self.assertRaises(ValueError, MockRestApiServer, None, '', bad_config) self.assertRaises(ValueError, MockRestApiServer, None, '', bad_config)
self.assertRaises(ValueError, self.srv.reload_config, bad_config) self.assertRaises(ValueError, self.srv.reload_config, bad_config)
self.assertRaises(ValueError, self.srv.reload_config, {}) self.assertRaises(ValueError, self.srv.reload_config, {})
with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)): with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)), \
patch.object(MockRestApiServer, 'server_close', Mock()):
self.srv.reload_config({'listen': ':8008'}) self.srv.reload_config({'listen': ':8008'})
@patch.object(MockPatroni, 'dcs') @patch.object(MockPatroni, 'dcs')
+3 -2
View File
@@ -8,11 +8,11 @@ from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.cancellable import CancellableSubprocess from patroni.postgresql.cancellable import CancellableSubprocess
from patroni.postgresql.config import ConfigHandler from patroni.postgresql.config import ConfigHandler
from . import psycopg2_connect, BaseTestPostgresql from . import psycopg_connect, BaseTestPostgresql
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
@patch('os.rename', Mock()) @patch('os.rename', Mock())
class TestBootstrap(BaseTestPostgresql): class TestBootstrap(BaseTestPostgresql):
@@ -164,6 +164,7 @@ class TestBootstrap(BaseTestPostgresql):
@patch('os.unlink', Mock()) @patch('os.unlink', Mock())
@patch('shutil.copy', Mock()) @patch('shutil.copy', Mock())
@patch('os.path.isfile', Mock(return_value=True)) @patch('os.path.isfile', Mock(return_value=True))
@patch('patroni.postgresql.bootstrap.quote_ident', Mock())
@patch.object(Bootstrap, 'call_post_bootstrap', Mock(return_value=True)) @patch.object(Bootstrap, 'call_post_bootstrap', Mock(return_value=True))
@patch.object(Bootstrap, '_custom_bootstrap', Mock(return_value=True)) @patch.object(Bootstrap, '_custom_bootstrap', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True)) @patch.object(Postgresql, 'start', Mock(return_value=True))
+2 -2
View File
@@ -27,8 +27,8 @@ class TestCancellableSubprocess(unittest.TestCase):
def test_cancel(self): def test_cancel(self):
self.c._process = Mock() self.c._process = Mock()
self.c._process.is_running.return_value = True self.c._process.is_running.return_value = True
self.c._process.children.side_effect = psutil.Error() self.c._process.children.side_effect = psutil.NoSuchProcess(123)
self.c._process.suspend.side_effect = psutil.Error() self.c._process.suspend.side_effect = psutil.AccessDenied()
self.c.cancel() self.c.cancel()
self.c._process.is_running.side_effect = [True, False] self.c._process.is_running.side_effect = [True, False]
self.c.cancel() self.c.cancel()
+3 -1
View File
@@ -2,7 +2,7 @@ import consul
import unittest import unittest
from consul import ConsulException, NotFound from consul import ConsulException, NotFound
from mock import Mock, patch from mock import Mock, PropertyMock, patch
from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \ from patroni.dcs.consul import AbstractDCS, Cluster, Consul, ConsulInternalError, \
ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession ConsulError, ConsulClient, HTTPClient, InvalidSessionTTL, InvalidSession
from . import SleepException from . import SleepException
@@ -154,8 +154,10 @@ class TestConsul(unittest.TestCase):
def test_set_config_value(self): def test_set_config_value(self):
self.c.set_config_value('') self.c.set_config_value('')
@patch.object(Cluster, 'min_version', PropertyMock(return_value=(2, 0)))
@patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException)) @patch.object(consul.Consul.KV, 'put', Mock(side_effect=ConsulException))
def test_write_leader_optime(self): def test_write_leader_optime(self):
self.c.get_cluster()
self.c.write_leader_optime('1') self.c.write_leader_optime('1')
@patch.object(consul.Consul.Session, 'renew', Mock()) @patch.object(consul.Consul.Session, 'renew', Mock())
+5 -5
View File
@@ -9,11 +9,11 @@ from patroni.ctl import ctl, store_config, load_config, output_members, get_dcs,
get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException, apply_config_changes, \ get_all_members, get_any_member, get_cursor, query_member, configure, PatroniCtlException, apply_config_changes, \
format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH format_config_for_editing, show_diff, invoke_editor, format_pg_version, CONFIG_FILE_PATH
from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Failover from patroni.dcs.etcd import AbstractEtcdClientWithFailover, Failover
from patroni.psycopg import OperationalError
from patroni.utils import tzutc from patroni.utils import tzutc
from psycopg2 import OperationalError
from urllib3 import PoolManager from urllib3 import PoolManager
from . import MockConnect, MockCursor, MockResponse, psycopg2_connect from . import MockConnect, MockCursor, MockResponse, psycopg_connect
from .test_etcd import etcd_read, socket_getaddrinfo from .test_etcd import etcd_read, socket_getaddrinfo
from .test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \ from .test_ha import get_cluster_initialized_without_leader, get_cluster_initialized_with_leader, \
get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader, get_cluster, Member get_cluster_initialized_with_only_leader, get_cluster_not_initialized_without_leader, get_cluster, Member
@@ -48,7 +48,7 @@ class TestCtl(unittest.TestCase):
self.assertRaises(PatroniCtlException, load_config, './non-existing-config-file', None) self.assertRaises(PatroniCtlException, load_config, './non-existing-config-file', None)
self.assertRaises(PatroniCtlException, load_config, './non-existing-config-file', None) self.assertRaises(PatroniCtlException, load_config, './non-existing-config-file', None)
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
def test_get_cursor(self): def test_get_cursor(self):
self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), {}, role='master')) self.assertIsNone(get_cursor(get_cluster_initialized_without_leader(), {}, role='master'))
@@ -57,7 +57,7 @@ class TestCtl(unittest.TestCase):
# MockCursor returns pg_is_in_recovery as false # MockCursor returns pg_is_in_recovery as false
self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), {}, role='replica')) self.assertIsNone(get_cursor(get_cluster_initialized_with_leader(), {}, role='replica'))
self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {'database': 'foo'}, role='any')) self.assertIsNotNone(get_cursor(get_cluster_initialized_with_leader(), {'dbname': 'foo'}, role='any'))
def test_parse_dcs(self): def test_parse_dcs(self):
assert parse_dcs(None) is None assert parse_dcs(None) is None
@@ -165,7 +165,7 @@ class TestCtl(unittest.TestCase):
def test_get_dcs(self): def test_get_dcs(self):
self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy') self.assertRaises(PatroniCtlException, get_dcs, {'dummy': {}}, 'dummy')
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
@patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None))) @patch('patroni.ctl.query_member', Mock(return_value=([['mock column']], None)))
@patch('patroni.ctl.get_dcs') @patch('patroni.ctl.get_dcs')
@patch.object(etcd.Client, 'read', etcd_read) @patch.object(etcd.Client, 'read', etcd_read)
+6 -1
View File
@@ -4,7 +4,7 @@ import socket
import unittest import unittest
from dns.exception import DNSException from dns.exception import DNSException
from mock import Mock, patch from mock import Mock, PropertyMock, patch
from patroni.dcs.etcd import AbstractDCS, EtcdClient, Cluster, Etcd, EtcdError, DnsCachingResolver from patroni.dcs.etcd import AbstractDCS, EtcdClient, Cluster, Etcd, EtcdError, DnsCachingResolver
from patroni.exceptions import DCSError from patroni.exceptions import DCSError
from patroni.utils import Retry from patroni.utils import Retry
@@ -277,7 +277,9 @@ class TestEtcd(unittest.TestCase):
self.etcd._base_path = '/service/failed' self.etcd._base_path = '/service/failed'
self.assertFalse(self.etcd.attempt_to_acquire_leader()) self.assertFalse(self.etcd.attempt_to_acquire_leader())
@patch.object(Cluster, 'min_version', PropertyMock(return_value=(2, 0)))
def test_write_leader_optime(self): def test_write_leader_optime(self):
self.etcd.get_cluster()
self.etcd.write_leader_optime('0') self.etcd.write_leader_optime('0')
def test_update_leader(self): def test_update_leader(self):
@@ -321,3 +323,6 @@ class TestEtcd(unittest.TestCase):
def test_set_history_value(self): def test_set_history_value(self):
self.assertFalse(self.etcd.set_history_value('{}')) self.assertFalse(self.etcd.set_history_value('{}'))
def test_last_seen(self):
self.assertIsNotNone(self.etcd.last_seen)
+61 -36
View File
@@ -19,7 +19,7 @@ from patroni.utils import tzutc
from patroni.watchdog import Watchdog from patroni.watchdog import Watchdog
from six.moves import builtins from six.moves import builtins
from . import PostgresInit, MockPostmaster, psycopg2_connect, requests_get from . import PostgresInit, MockPostmaster, psycopg_connect, requests_get
from .test_etcd import socket_getaddrinfo, etcd_read, etcd_write from .test_etcd import socket_getaddrinfo, etcd_read, etcd_write
SYSID = '12345678901' SYSID = '12345678901'
@@ -80,13 +80,14 @@ def get_standby_cluster_initialized_with_only_leader(failover=None, sync=None):
) )
def get_node_status(reachable=True, in_recovery=True, timeline=2, def get_node_status(reachable=True, in_recovery=True, dcs_last_seen=0,
wal_position=10, nofailover=False, watchdog_failed=False): timeline=2, wal_position=10, nofailover=False,
watchdog_failed=False):
def fetch_node_status(e): def fetch_node_status(e):
tags = {} tags = {}
if nofailover: if nofailover:
tags['nofailover'] = True tags['nofailover'] = True
return _MemberStatus(e, reachable, in_recovery, timeline, wal_position, tags, watchdog_failed) return _MemberStatus(e, reachable, in_recovery, dcs_last_seen, timeline, wal_position, tags, watchdog_failed)
return fetch_node_status return fetch_node_status
@@ -322,7 +323,7 @@ class TestHa(PostgresInit):
self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID} self.p.controldata = lambda: {'Database cluster state': 'in production', 'Database system identifier': SYSID}
self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because I had the session lock') self.assertEqual(self.ha.run_cycle(), 'promoted self to leader because I had the session lock')
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
def test_acquire_lock_as_master(self): def test_acquire_lock_as_master(self):
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader') self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
@@ -349,7 +350,7 @@ class TestHa(PostgresInit):
self.ha.has_lock = true self.ha.has_lock = true
self.p.is_leader = false self.p.is_leader = false
self.p.set_role('master') self.p.set_role('master')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
def test_demote_after_failing_to_obtain_lock(self): def test_demote_after_failing_to_obtain_lock(self):
self.ha.acquire_lock = false self.ha.acquire_lock = false
@@ -389,7 +390,7 @@ class TestHa(PostgresInit):
self.ha.cluster = get_cluster_initialized_with_leader() self.ha.cluster = get_cluster_initialized_with_leader()
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
self.ha.has_lock = true self.ha.has_lock = true
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
def test_demote_because_not_having_lock(self): def test_demote_because_not_having_lock(self):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
@@ -401,6 +402,8 @@ class TestHa(PostgresInit):
self.ha.has_lock = true self.ha.has_lock = true
self.ha.update_lock = false self.ha.update_lock = false
self.assertEqual(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS') self.assertEqual(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
with patch.object(Ha, '_get_node_to_follow', Mock(side_effect=DCSError('foo'))):
self.assertEqual(self.ha.run_cycle(), 'demoted self because failed to update leader lock in DCS')
self.p.is_leader = false self.p.is_leader = false
self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS') self.assertEqual(self.ha.run_cycle(), 'not promoting because failed to update leader lock in DCS')
@@ -408,16 +411,16 @@ class TestHa(PostgresInit):
def test_follow(self): def test_follow(self):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
self.p.is_leader = false self.p.is_leader = false
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
self.ha.patroni.replicatefrom = "foo" self.ha.patroni.replicatefrom = "foo"
self.p.config.check_recovery_conf = Mock(return_value=(True, False)) self.p.config.check_recovery_conf = Mock(return_value=(True, False))
self.ha.cluster.config.data.update({'slots': {'l': {'database': 'a', 'plugin': 'b'}}}) self.ha.cluster.config.data.update({'slots': {'l': {'database': 'a', 'plugin': 'b'}}})
self.ha.cluster.members[1].data['tags']['replicatefrom'] = 'postgresql0' self.ha.cluster.members[1].data['tags']['replicatefrom'] = 'postgresql0'
self.ha.patroni.nofailover = True self.ha.patroni.nofailover = True
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
del self.ha.cluster.config.data['slots'] del self.ha.cluster.config.data['slots']
self.ha.cluster.config.data.update({'postgresql': {'use_slots': False}}) self.ha.cluster.config.data.update({'postgresql': {'use_slots': False}})
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), a secondary, and following a leader ()')
del self.ha.cluster.config.data['postgresql']['use_slots'] del self.ha.cluster.config.data['postgresql']['use_slots']
def test_follow_in_pause(self): def test_follow_in_pause(self):
@@ -457,6 +460,8 @@ class TestHa(PostgresInit):
self.ha.cluster = get_cluster_not_initialized_without_leader() self.ha.cluster = get_cluster_not_initialized_without_leader()
self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock') self.assertEqual(self.ha.bootstrap(), 'failed to acquire initialize lock')
@patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(Postgresql, 'connection', Mock(return_value=None))
def test_bootstrap_initialized_new_cluster(self): def test_bootstrap_initialized_new_cluster(self):
self.ha.cluster = get_cluster_not_initialized_without_leader() self.ha.cluster = get_cluster_not_initialized_without_leader()
self.e.initialize = true self.e.initialize = true
@@ -474,6 +479,8 @@ class TestHa(PostgresInit):
self.p.is_running = false self.p.is_running = false
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap) self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
@patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(Postgresql, 'connection', Mock(return_value=None))
def test_bootstrap_release_initialize_key_on_watchdog_failure(self): def test_bootstrap_release_initialize_key_on_watchdog_failure(self):
self.ha.cluster = get_cluster_not_initialized_without_leader() self.ha.cluster = get_cluster_not_initialized_without_leader()
self.e.initialize = true self.e.initialize = true
@@ -484,7 +491,7 @@ class TestHa(PostgresInit):
self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap') self.assertEqual(self.ha.post_bootstrap(), 'running post_bootstrap')
self.assertRaises(PatroniFatalException, self.ha.post_bootstrap) self.assertRaises(PatroniFatalException, self.ha.post_bootstrap)
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
def test_reinitialize(self): def test_reinitialize(self):
self.assertIsNotNone(self.ha.reinitialize()) self.assertIsNotNone(self.ha.reinitialize())
@@ -536,27 +543,27 @@ class TestHa(PostgresInit):
self.ha.fetch_node_status = get_node_status() self.ha.fetch_node_status = get_node_status()
self.ha.has_lock = true self.ha.has_lock = true
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', '', None))
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', self.p.name, None))
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, '', 'blabla', None))
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
f = Failover(0, self.p.name, '', None) f = Failover(0, self.p.name, '', None)
self.ha.cluster = get_cluster_initialized_with_leader(f) self.ha.cluster = get_cluster_initialized_with_leader(f)
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself') self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
self.ha._rewind.rewind_or_reinitialize_needed_and_possible = true self.ha._rewind.rewind_or_reinitialize_needed_and_possible = true
self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself') self.assertEqual(self.ha.run_cycle(), 'manual failover: demoting myself')
self.ha.fetch_node_status = get_node_status(nofailover=True) self.ha.fetch_node_status = get_node_status(nofailover=True)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.fetch_node_status = get_node_status(watchdog_failed=True) self.ha.fetch_node_status = get_node_status(watchdog_failed=True)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.fetch_node_status = get_node_status(timeline=1) self.ha.fetch_node_status = get_node_status(timeline=1)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
self.ha.fetch_node_status = get_node_status(wal_position=1) self.ha.fetch_node_status = get_node_status(wal_position=1)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
# manual failover from the previous leader to us won't happen if we hold the nofailover flag # manual failover from the previous leader to us won't happen if we hold the nofailover flag
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, None))
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
# Failover scheduled time must include timezone # Failover scheduled time must include timezone
scheduled = datetime.datetime.now() scheduled = datetime.datetime.now()
@@ -565,28 +572,28 @@ class TestHa(PostgresInit):
scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc) scheduled = datetime.datetime.utcnow().replace(tzinfo=tzutc)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle()) self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
scheduled = scheduled + datetime.timedelta(seconds=30) scheduled = scheduled + datetime.timedelta(seconds=30)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle()) self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
scheduled = scheduled + datetime.timedelta(seconds=-600) scheduled = scheduled + datetime.timedelta(seconds=-600)
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle()) self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
scheduled = None scheduled = None
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle()) self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
def test_manual_failover_from_leader_in_pause(self): def test_manual_failover_from_leader_in_pause(self):
self.ha.has_lock = true self.ha.has_lock = true
self.ha.is_paused = true self.ha.is_paused = true
scheduled = datetime.datetime.now() scheduled = datetime.datetime.now()
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, 'blabla', self.p.name, scheduled))
self.assertEqual('PAUSE: no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle()) self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, '', None))
self.assertEqual('PAUSE: no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle()) self.assertEqual('PAUSE: no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
def test_manual_failover_from_leader_in_synchronous_mode(self): def test_manual_failover_from_leader_in_synchronous_mode(self):
self.p.is_leader = true self.p.is_leader = true
@@ -595,7 +602,7 @@ class TestHa(PostgresInit):
self.ha.is_failover_possible = false self.ha.is_failover_possible = false
self.ha.process_sync_replication = Mock() self.ha.process_sync_replication = Mock()
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, None)) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, None))
self.assertEqual('no action. I am (postgresql0) the leader with the lock', self.ha.run_cycle()) self.assertEqual('no action. I am (postgresql0), the leader with the lock', self.ha.run_cycle())
self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, 'a')) self.ha.cluster = get_cluster_initialized_with_leader(Failover(0, self.p.name, 'a', None), (self.p.name, 'a'))
self.ha.is_failover_possible = true self.ha.is_failover_possible = true
self.assertEqual('manual failover: demoting myself', self.ha.run_cycle()) self.assertEqual('manual failover: demoting myself', self.ha.run_cycle())
@@ -623,6 +630,11 @@ class TestHa(PostgresInit):
# same as previous, but set the current member to nofailover. In no case it should be elected as a leader # same as previous, but set the current member to nofailover. In no case it should be elected as a leader
self.ha.patroni.nofailover = True self.ha.patroni.nofailover = True
self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote') self.assertEqual(self.ha.run_cycle(), 'following a different leader because I am not allowed to promote')
# in sync mode only the sync node is allowed to take over
self.ha.cluster = get_cluster_initialized_without_leader(failover=Failover(0, 'leader', 'other', None))
self.ha.patroni.nofailover = False
self.ha.is_synchronous_mode = true
self.assertEqual(self.ha.run_cycle(), 'following a different leader because i am not the healthiest node')
def test_manual_failover_process_no_leader_in_pause(self): def test_manual_failover_process_no_leader_in_pause(self):
self.ha.is_paused = true self.ha.is_paused = true
@@ -756,7 +768,7 @@ class TestHa(PostgresInit):
self.p.config.check_recovery_conf = Mock(return_value=(False, False)) self.p.config.check_recovery_conf = Mock(return_value=(False, False))
self.ha._leader_timeline = 1 self.ha._leader_timeline = 1
self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock') self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock')
self.assertEqual(self.ha.run_cycle(), 'no action. I am (leader) the standby leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (leader), the standby leader with the lock')
self.p.set_role('replica') self.p.set_role('replica')
self.p.config.check_recovery_conf = Mock(return_value=(True, False)) self.p.config.check_recovery_conf = Mock(return_value=(True, False))
self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock') self.assertEqual(self.ha.run_cycle(), 'promoted self to a standby leader because i had the session lock')
@@ -766,7 +778,7 @@ class TestHa(PostgresInit):
self.p.name = 'replica' self.p.name = 'replica'
self.ha.cluster = get_standby_cluster_initialized_with_only_leader() self.ha.cluster = get_standby_cluster_initialized_with_only_leader()
self.assertEqual(self.ha.run_cycle(), self.assertEqual(self.ha.run_cycle(),
'no action. I am a secondary (replica) and following a standby leader (leader)') 'no action. I am (replica), a secondary, and following a standby leader (leader)')
with patch.object(Leader, 'conn_url', PropertyMock(return_value='')): with patch.object(Leader, 'conn_url', PropertyMock(return_value='')):
self.assertEqual(self.ha.run_cycle(), 'continue following the old known standby leader') self.assertEqual(self.ha.run_cycle(), 'continue following the old known standby leader')
@@ -860,7 +872,7 @@ class TestHa(PostgresInit):
self.ha.has_lock = false self.ha.has_lock = false
self.p.is_leader = false self.p.is_leader = false
self.assertEqual(self.ha.run_cycle(), self.assertEqual(self.ha.run_cycle(),
'no action. I am a secondary (postgresql0) and following a leader (leader)') 'no action. I am (postgresql0), a secondary, and following a leader (leader)')
check_calls([(update_lock, False), (demote, False)]) check_calls([(update_lock, False), (demote, False)])
def test_manual_failover_while_starting(self): def test_manual_failover_while_starting(self):
@@ -1085,7 +1097,7 @@ class TestHa(PostgresInit):
self.ha.cluster.config.data.clear() self.ha.cluster.config.data.clear()
self.ha.has_lock = true self.ha.has_lock = true
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
def test_watch(self): def test_watch(self):
self.ha.cluster = get_cluster_initialized_with_leader() self.ha.cluster = get_cluster_initialized_with_leader()
@@ -1097,6 +1109,14 @@ class TestHa(PostgresInit):
def test_shutdown(self): def test_shutdown(self):
self.p.is_running = false self.p.is_running = false
self.ha.is_leader = true self.ha.is_leader = true
def stop(*args, **kwargs):
kwargs['on_shutdown'](123)
self.p.stop = stop
self.ha.shutdown()
self.ha.is_failover_possible = true
self.ha.shutdown() self.ha.shutdown()
@patch('time.sleep', Mock()) @patch('time.sleep', Mock())
@@ -1120,7 +1140,7 @@ class TestHa(PostgresInit):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
for tl in (1, 3): for tl in (1, 3):
self.p.get_master_timeline = Mock(return_value=tl) self.p.get_master_timeline = Mock(return_value=tl)
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
@patch('sys.exit', return_value=1) @patch('sys.exit', return_value=1)
def test_abort_join(self, exit_mock): def test_abort_join(self, exit_mock):
@@ -1133,11 +1153,11 @@ class TestHa(PostgresInit):
self.ha.has_lock = true self.ha.has_lock = true
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
self.ha.is_paused = true self.ha.is_paused = true
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0), the leader with the lock')
self.ha.is_paused = false self.ha.is_paused = false
self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (postgresql0), the leader with the lock')
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
def test_permanent_logical_slots_after_promote(self): def test_permanent_logical_slots_after_promote(self):
config = ClusterConfig(1, {'slots': {'l': {'database': 'postgres', 'plugin': 'test_decoding'}}}, 1) config = ClusterConfig(1, {'slots': {'l': {'database': 'postgres', 'plugin': 'test_decoding'}}}, 1)
self.p.name = 'other' self.p.name = 'other'
@@ -1145,7 +1165,7 @@ class TestHa(PostgresInit):
self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader') self.assertEqual(self.ha.run_cycle(), 'acquired session lock as a leader')
self.ha.cluster = get_cluster_initialized_without_leader(leader=True, cluster_config=config) self.ha.cluster = get_cluster_initialized_without_leader(leader=True, cluster_config=config)
self.ha.has_lock = true self.ha.has_lock = true
self.assertEqual(self.ha.run_cycle(), 'no action. I am (other) the leader with the lock') self.assertEqual(self.ha.run_cycle(), 'no action. I am (other), the leader with the lock')
@patch.object(Cluster, 'has_member', true) @patch.object(Cluster, 'has_member', true)
def test_run_cycle(self): def test_run_cycle(self):
@@ -1169,7 +1189,7 @@ class TestHa(PostgresInit):
self.ha.has_lock = true self.ha.has_lock = true
self.assertEqual(self.ha.run_cycle(), 'PAUSE: released leader key voluntarily due to the system ID mismatch') self.assertEqual(self.ha.run_cycle(), 'PAUSE: released leader key voluntarily due to the system ID mismatch')
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
@patch('os.path.exists', Mock(return_value=True)) @patch('os.path.exists', Mock(return_value=True))
@patch('shutil.rmtree', Mock()) @patch('shutil.rmtree', Mock())
@patch('os.makedirs', Mock()) @patch('os.makedirs', Mock())
@@ -1184,3 +1204,8 @@ class TestHa(PostgresInit):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
self.p.is_leader = false self.p.is_leader = false
self.assertTrue(self.ha.run_cycle().startswith('Copying logical slots')) self.assertTrue(self.ha.run_cycle().startswith('Copying logical slots'))
def test_is_failover_possible(self):
self.ha.fetch_node_status = Mock(return_value=_MemberStatus(self.ha.cluster.members[0],
True, True, 0, 2, None, {}, False))
self.assertFalse(self.ha.is_failover_possible(self.ha.cluster.members))
+17 -8
View File
@@ -13,15 +13,24 @@ from patroni.dcs.etcd import AbstractEtcdClientWithFailover
from patroni.exceptions import DCSError from patroni.exceptions import DCSError
from patroni.postgresql import Postgresql from patroni.postgresql import Postgresql
from patroni.postgresql.config import ConfigHandler from patroni.postgresql.config import ConfigHandler
from patroni import Patroni, main as _main, patroni_main, check_psycopg2 from patroni import check_psycopg
from patroni.__main__ import Patroni, main as _main, patroni_main
from six.moves import BaseHTTPServer, builtins from six.moves import BaseHTTPServer, builtins
from threading import Thread from threading import Thread
from . import psycopg2_connect, SleepException from . import psycopg_connect, SleepException
from .test_etcd import etcd_read, etcd_write from .test_etcd import etcd_read, etcd_write
from .test_postgresql import MockPostmaster from .test_postgresql import MockPostmaster
def mock_import(*args, **kwargs):
if args[0] == 'psycopg':
raise ImportError
ret = Mock()
ret.__version__ = '2.5.3.dev1 a b c'
return ret
class MockFrozenImporter(object): class MockFrozenImporter(object):
toc = set(['patroni.dcs.etcd']) toc = set(['patroni.dcs.etcd'])
@@ -29,7 +38,7 @@ class MockFrozenImporter(object):
@patch('time.sleep', Mock()) @patch('time.sleep', Mock())
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(ConfigHandler, 'append_pg_hba', Mock()) @patch.object(ConfigHandler, 'append_pg_hba', Mock())
@patch.object(ConfigHandler, 'write_postgresql_conf', Mock()) @patch.object(ConfigHandler, 'write_postgresql_conf', Mock())
@patch.object(ConfigHandler, 'write_recovery_conf', Mock()) @patch.object(ConfigHandler, 'write_recovery_conf', Mock())
@@ -89,7 +98,7 @@ class TestPatroni(unittest.TestCase):
@patch('os.getpid') @patch('os.getpid')
@patch('multiprocessing.Process') @patch('multiprocessing.Process')
@patch('patroni.patroni_main', Mock()) @patch('patroni.__main__.patroni_main', Mock())
def test_patroni_main(self, mock_process, mock_getpid): def test_patroni_main(self, mock_process, mock_getpid):
mock_getpid.return_value = 2 mock_getpid.return_value = 2
_main() _main()
@@ -181,8 +190,8 @@ class TestPatroni(unittest.TestCase):
self.p.ha.shutdown = Mock(side_effect=Exception) self.p.ha.shutdown = Mock(side_effect=Exception)
self.p.shutdown() self.p.shutdown()
def test_check_psycopg2(self): def test_check_psycopg(self):
with patch.object(builtins, '__import__', Mock(side_effect=ImportError)): with patch.object(builtins, '__import__', Mock(side_effect=ImportError)):
self.assertRaises(SystemExit, check_psycopg2) self.assertRaises(SystemExit, check_psycopg)
with patch('psycopg2.__version__', '2.5.3.dev1 a b c'): with patch.object(builtins, '__import__', mock_import):
self.assertRaises(SystemExit, check_psycopg2) self.assertRaises(SystemExit, check_psycopg)
+28 -11
View File
@@ -1,12 +1,14 @@
import datetime import datetime
import os import os
import psutil import psutil
import psycopg2
import re import re
import subprocess import subprocess
import time import time
from mock import Mock, MagicMock, PropertyMock, patch, mock_open from mock import Mock, MagicMock, PropertyMock, patch, mock_open
import patroni.psycopg as psycopg
from patroni.async_executor import CriticalTask from patroni.async_executor import CriticalTask
from patroni.dcs import Cluster, RemoteMember, SyncState from patroni.dcs import Cluster, RemoteMember, SyncState
from patroni.exceptions import PostgresConnectionException, PatroniException from patroni.exceptions import PostgresConnectionException, PatroniException
@@ -17,7 +19,7 @@ from patroni.utils import RetryFailedError
from six.moves import builtins from six.moves import builtins
from threading import Thread, current_thread from threading import Thread, current_thread
from . import BaseTestPostgresql, MockCursor, MockPostmaster, psycopg2_connect from . import BaseTestPostgresql, MockCursor, MockPostmaster, psycopg_connect
mtime_ret = {} mtime_ret = {}
@@ -87,7 +89,7 @@ Data page checksum version: 0
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
class TestPostgresql(BaseTestPostgresql): class TestPostgresql(BaseTestPostgresql):
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@@ -203,6 +205,21 @@ class TestPostgresql(BaseTestPostgresql):
mock_postmaster.signal_stop.side_effect = [None, True] mock_postmaster.signal_stop.side_effect = [None, True]
self.assertTrue(self.p.stop(on_safepoint=mock_callback, stop_timeout=30)) self.assertTrue(self.p.stop(on_safepoint=mock_callback, stop_timeout=30))
@patch('time.sleep', Mock())
@patch.object(Postgresql, 'is_running', MockPostmaster)
@patch.object(Postgresql, '_wait_for_connection_close', Mock())
@patch.object(Postgresql, 'latest_checkpoint_location', Mock(return_value='7'))
def test__do_stop(self):
mock_callback = Mock()
with patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down'})):
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
mock_callback.assert_called()
with patch.object(Postgresql, 'controldata',
Mock(return_value={'Database cluster state': 'shut down in recovery'})):
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
with patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shutting down'})):
self.assertTrue(self.p.stop(on_shutdown=mock_callback, stop_timeout=3))
def test_restart(self): def test_restart(self):
self.p.start = Mock(return_value=False) self.p.start = Mock(return_value=False)
self.assertFalse(self.p.restart()) self.assertFalse(self.p.restart())
@@ -243,8 +260,8 @@ class TestPostgresql(BaseTestPostgresql):
with patch('patroni.postgresql.config.ConfigHandler.primary_conninfo_params', Mock(return_value=conninfo)): with patch('patroni.postgresql.config.ConfigHandler.primary_conninfo_params', Mock(return_value=conninfo)):
mock_get_pg_settings.return_value['recovery_min_apply_delay'][1] = '1' mock_get_pg_settings.return_value['recovery_min_apply_delay'][1] = '1'
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True)) self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
mock_get_pg_settings.return_value['primary_conninfo'][1] = 'host=1 passfile='\ mock_get_pg_settings.return_value['primary_conninfo'][1] = 'host=1 target_session_attrs=read-write'\
+ re.sub(r'([\'\\ ])', r'\\\1', self.p.config._pgpass) + ' passfile=' + re.sub(r'([\'\\ ])', r'\\\1', self.p.config._pgpass)
mock_get_pg_settings.return_value['recovery_min_apply_delay'][1] = '0' mock_get_pg_settings.return_value['recovery_min_apply_delay'][1] = '0'
self.assertEqual(self.p.config.check_recovery_conf(None), (True, True)) self.assertEqual(self.p.config.check_recovery_conf(None), (True, True))
self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': conninfo.copy()}) self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': conninfo.copy()})
@@ -304,7 +321,7 @@ class TestPostgresql(BaseTestPostgresql):
m = RemoteMember('1', {'restore_command': '2', 'primary_slot_name': 'foo', 'conn_kwargs': {'host': 'bar'}}) m = RemoteMember('1', {'restore_command': '2', 'primary_slot_name': 'foo', 'conn_kwargs': {'host': 'bar'}})
self.p.follow(m) self.p.follow(m)
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)) @patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError))
def test__query(self): def test__query(self):
self.assertRaises(PostgresConnectionException, self.p._query, 'blabla') self.assertRaises(PostgresConnectionException, self.p._query, 'blabla')
self.p._state = 'restarting' self.p._state = 'restarting'
@@ -313,14 +330,14 @@ class TestPostgresql(BaseTestPostgresql):
def test_query(self): def test_query(self):
self.p.query('select 1') self.p.query('select 1')
self.assertRaises(PostgresConnectionException, self.p.query, 'RetryFailedError') self.assertRaises(PostgresConnectionException, self.p.query, 'RetryFailedError')
self.assertRaises(psycopg2.ProgrammingError, self.p.query, 'blabla') self.assertRaises(psycopg.ProgrammingError, self.p.query, 'blabla')
@patch.object(Postgresql, 'pg_isready', Mock(return_value=STATE_REJECT)) @patch.object(Postgresql, 'pg_isready', Mock(return_value=STATE_REJECT))
def test_is_leader(self): def test_is_leader(self):
self.assertTrue(self.p.is_leader()) self.assertTrue(self.p.is_leader())
self.p.reset_cluster_info_state(None) self.p.reset_cluster_info_state(None)
with patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError(''))): with patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError(''))):
self.assertRaises(PostgresConnectionException, self.p.is_leader) self.assertFalse(self.p.is_leader())
@patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down', @patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down',
'Latest checkpoint location': '0/1ADBC18', 'Latest checkpoint location': '0/1ADBC18',
@@ -415,7 +432,7 @@ class TestPostgresql(BaseTestPostgresql):
@patch.object(Postgresql, 'is_running', Mock(return_value=MockPostmaster())) @patch.object(Postgresql, 'is_running', Mock(return_value=MockPostmaster()))
def test_is_leader_exception(self): def test_is_leader_exception(self):
self.p.start() self.p.start()
self.p.query = Mock(side_effect=psycopg2.OperationalError("not supported")) self.p.query = Mock(side_effect=psycopg.OperationalError("not supported"))
self.assertTrue(self.p.stop()) self.assertTrue(self.p.stop())
@patch('os.rename', Mock()) @patch('os.rename', Mock())
@@ -544,7 +561,7 @@ class TestPostgresql(BaseTestPostgresql):
t.start() t.start()
t.join() t.join()
with patch.object(MockCursor, "execute", side_effect=psycopg2.Error): with patch.object(MockCursor, "execute", side_effect=psycopg.Error):
self.assertIsNone(self.p.postmaster_start_time()) self.assertIsNone(self.p.postmaster_start_time())
def test_check_for_startup(self): def test_check_for_startup(self):
@@ -707,7 +724,7 @@ class TestPostgresql(BaseTestPostgresql):
self.p.stop(on_safepoint=mock_callback) self.p.stop(on_safepoint=mock_callback)
mock_postmaster.is_running.side_effect = [True, False, False] mock_postmaster.is_running.side_effect = [True, False, False]
with patch.object(MockCursor, "execute", Mock(side_effect=psycopg2.Error)): with patch.object(MockCursor, "execute", Mock(side_effect=psycopg.Error)):
self.p.stop(on_safepoint=mock_callback) self.p.stop(on_safepoint=mock_callback)
def test_terminate_starting_postmaster(self): def test_terminate_starting_postmaster(self):
+4 -4
View File
@@ -73,7 +73,7 @@ class TestPostmasterProcess(unittest.TestCase):
# all processes successfully stopped # all processes successfully stopped
mock_children.return_value = [Mock()] mock_children.return_value = [Mock()]
mock_children.return_value[0].kill.side_effect = psutil.Error mock_children.return_value[0].kill.side_effect = psutil.NoSuchProcess(123)
self.assertTrue(proc.signal_kill()) self.assertTrue(proc.signal_kill())
# postmaster has gone before suspend # postmaster has gone before suspend
@@ -81,17 +81,17 @@ class TestPostmasterProcess(unittest.TestCase):
self.assertTrue(proc.signal_kill()) self.assertTrue(proc.signal_kill())
# postmaster has gone before we got a list of children # postmaster has gone before we got a list of children
mock_suspend.side_effect = psutil.Error() mock_suspend.side_effect = psutil.AccessDenied()
mock_children.side_effect = psutil.NoSuchProcess(123) mock_children.side_effect = psutil.NoSuchProcess(123)
self.assertTrue(proc.signal_kill()) self.assertTrue(proc.signal_kill())
# postmaster has gone after we got a list of children # postmaster has gone after we got a list of children
mock_children.side_effect = psutil.Error() mock_children.side_effect = psutil.AccessDenied()
mock_kill.side_effect = psutil.NoSuchProcess(123) mock_kill.side_effect = psutil.NoSuchProcess(123)
self.assertTrue(proc.signal_kill()) self.assertTrue(proc.signal_kill())
# failed to kill postmaster # failed to kill postmaster
mock_kill.side_effect = psutil.AccessDenied(123) mock_kill.side_effect = psutil.AccessDenied()
self.assertFalse(proc.signal_kill()) self.assertFalse(proc.signal_kill())
@patch('psutil.Process.__init__', Mock()) @patch('psutil.Process.__init__', Mock())
+1 -1
View File
@@ -157,6 +157,6 @@ class TestRaft(unittest.TestCase):
@patch('threading.Event') @patch('threading.Event')
def test_init(self, mock_event, mock_kvstore): def test_init(self, mock_event, mock_kvstore):
mock_kvstore.return_value.applied_local_log = False mock_kvstore.return_value.applied_local_log = False
mock_event.return_value.isSet.side_effect = [False, True] mock_event.return_value.is_set.side_effect = [False, True]
self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True, self.assertIsNotNone(Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'patronictl': True,
'self_addr': '1', 'data_dir': self._TMP})) 'self_addr': '1', 'data_dir': self._TMP}))
+26 -14
View File
@@ -5,7 +5,7 @@ from patroni.postgresql.cancellable import CancellableSubprocess
from patroni.postgresql.rewind import Rewind from patroni.postgresql.rewind import Rewind
from six.moves import builtins from six.moves import builtins
from . import BaseTestPostgresql, MockCursor, psycopg2_connect from . import BaseTestPostgresql, MockCursor, psycopg_connect
class MockThread(object): class MockThread(object):
@@ -47,7 +47,7 @@ def mock_single_user_mode(self, communicate, options):
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
class TestRewind(BaseTestPostgresql): class TestRewind(BaseTestPostgresql):
def setUp(self): def setUp(self):
@@ -102,6 +102,11 @@ class TestRewind(BaseTestPostgresql):
@patch.object(Postgresql, 'start', Mock()) @patch.object(Postgresql, 'start', Mock())
def test_execute(self, mock_checkpoint): def test_execute(self, mock_checkpoint):
self.r.execute(self.leader) self.r.execute(self.leader)
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=130000)):
self.r.execute(self.leader)
with patch.object(MockCursor, 'fetchone', Mock(side_effect=Exception)):
self.r.execute(self.leader)
with patch.object(Rewind, 'pg_rewind', Mock(return_value=False)): with patch.object(Rewind, 'pg_rewind', Mock(return_value=False)):
mock_checkpoint.side_effect = ['1', '', '', ''] mock_checkpoint.side_effect = ['1', '', '', '']
self.r.execute(self.leader) self.r.execute(self.leader)
@@ -143,11 +148,16 @@ class TestRewind(BaseTestPostgresql):
mock_check_leader_is_not_in_recovery.return_value = True mock_check_leader_is_not_in_recovery.return_value = True
self.assertFalse(self.r.rewind_or_reinitialize_needed_and_possible(self.leader)) self.assertFalse(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
self.r.trigger_check_diverged_lsn() self.r.trigger_check_diverged_lsn()
with patch('psycopg2.connect', Mock(side_effect=Exception)): with patch.object(MockCursor, 'fetchone', Mock(side_effect=[('', 3, '0/0'), ('', b'4\t0/40159C0\tn\n')])):
self.assertTrue(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
self.r.reset_state()
self.r.trigger_check_diverged_lsn()
with patch('patroni.psycopg.connect', Mock(side_effect=Exception)):
self.assertFalse(self.r.rewind_or_reinitialize_needed_and_possible(self.leader)) self.assertFalse(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
self.r.trigger_check_diverged_lsn() self.r.trigger_check_diverged_lsn()
with patch.object(MockCursor, 'fetchone', Mock(side_effect=[('', 3, '0/0'), ('', b'3\t0/40159C0\tn\n')])): with patch.object(MockCursor, 'fetchone', Mock(side_effect=[('', 3, '0/0'), ('', b'1\t0/40159C0\tn\n')])):
self.assertFalse(self.r.rewind_or_reinitialize_needed_and_possible(self.leader)) self.assertTrue(self.r.rewind_or_reinitialize_needed_and_possible(self.leader))
self.r.reset_state()
self.r.trigger_check_diverged_lsn() self.r.trigger_check_diverged_lsn()
with patch.object(MockCursor, 'fetchone', Mock(return_value=('', 1, '0/0'))): with patch.object(MockCursor, 'fetchone', Mock(return_value=('', 1, '0/0'))):
with patch.object(Rewind, '_get_local_timeline_lsn', Mock(return_value=(True, 1, '0/0'))): with patch.object(Rewind, '_get_local_timeline_lsn', Mock(return_value=(True, 1, '0/0'))):
@@ -219,18 +229,20 @@ class TestRewind(BaseTestPostgresql):
@patch('patroni.postgresql.rewind.Thread', MockThread) @patch('patroni.postgresql.rewind.Thread', MockThread)
@patch.object(Postgresql, 'controldata') @patch.object(Postgresql, 'controldata')
@patch.object(Postgresql, 'checkpoint') @patch.object(Postgresql, 'checkpoint')
def test_ensure_checkpoint_after_promote(self, mock_checkpoint, mock_controldata): @patch.object(Postgresql, 'get_master_timeline')
mock_checkpoint.return_value = None def test_ensure_checkpoint_after_promote(self, mock_get_master_timeline, mock_checkpoint, mock_controldata):
mock_controldata.return_value = {"Latest checkpoint's TimeLineID": 1}
mock_get_master_timeline.return_value = 1
self.r.ensure_checkpoint_after_promote(Mock())
self.r.reset_state()
mock_get_master_timeline.return_value = 2
mock_checkpoint.return_value = 0
self.r.ensure_checkpoint_after_promote(Mock()) self.r.ensure_checkpoint_after_promote(Mock())
self.r.ensure_checkpoint_after_promote(Mock()) self.r.ensure_checkpoint_after_promote(Mock())
self.r.reset_state() self.r.reset_state()
mock_controldata.return_value = {"Latest checkpoint's TimeLineID": 1}
mock_controldata.side_effect = TypeError
mock_checkpoint.side_effect = Exception mock_checkpoint.side_effect = Exception
self.r.ensure_checkpoint_after_promote(Mock()) self.r.ensure_checkpoint_after_promote(Mock())
self.r.ensure_checkpoint_after_promote(Mock())
self.r.reset_state()
mock_controldata.side_effect = TypeError
self.r.ensure_checkpoint_after_promote(Mock())
self.r.ensure_checkpoint_after_promote(Mock())
+8 -6
View File
@@ -1,20 +1,20 @@
import mock import mock
import os import os
import psycopg2
import unittest import unittest
from mock import Mock, PropertyMock, patch from mock import Mock, PropertyMock, patch
from patroni import psycopg
from patroni.dcs import Cluster, ClusterConfig, Member from patroni.dcs import Cluster, ClusterConfig, Member
from patroni.postgresql import Postgresql from patroni.postgresql import Postgresql
from patroni.postgresql.slots import SlotsHandler, fsync_dir from patroni.postgresql.slots import SlotsHandler, fsync_dir
from . import BaseTestPostgresql, psycopg2_connect, MockCursor from . import BaseTestPostgresql, psycopg_connect, MockCursor
@patch('subprocess.call', Mock(return_value=0)) @patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
@patch.object(Postgresql, 'is_running', Mock(return_value=True)) @patch.object(Postgresql, 'is_running', Mock(return_value=True))
class TestSlotsHandler(BaseTestPostgresql): class TestSlotsHandler(BaseTestPostgresql):
@@ -34,7 +34,7 @@ class TestSlotsHandler(BaseTestPostgresql):
'ignore_slots': [{'name': 'blabla'}]}, 1) 'ignore_slots': [{'name': 'blabla'}]}, 1)
cluster = Cluster(True, config, self.leader, 0, cluster = Cluster(True, config, self.leader, 0,
[self.me, self.other, self.leadermem], None, None, None, {'test_3': 10}) [self.me, self.other, self.leadermem], None, None, None, {'test_3': 10})
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)): with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg.OperationalError)):
self.s.sync_replication_slots(cluster, False) self.s.sync_replication_slots(cluster, False)
self.p.set_role('standby_leader') self.p.set_role('standby_leader')
self.s.sync_replication_slots(cluster, False) self.s.sync_replication_slots(cluster, False)
@@ -86,14 +86,16 @@ class TestSlotsHandler(BaseTestPostgresql):
[self.me, self.other, self.leadermem], None, None, None, {'ls': 12346}) [self.me, self.other, self.leadermem], None, None, None, {'ls': 12346})
self.assertEqual(self.s.sync_replication_slots(cluster, False), []) self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
self.s._schedule_load_slots = False self.s._schedule_load_slots = False
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.errors.UndefinedFile)): with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError)),\
patch.object(psycopg.OperationalError, 'diag') as mock_diag:
type(mock_diag).sqlstate = PropertyMock(return_value='58P01')
self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls']) self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls'])
cluster.slots['ls'] = 'a' cluster.slots['ls'] = 'a'
self.assertEqual(self.s.sync_replication_slots(cluster, False), []) self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True): with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls']) self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls'])
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)) @patch.object(MockCursor, 'execute', Mock(side_effect=psycopg.OperationalError))
def test_copy_logical_slots(self): def test_copy_logical_slots(self):
self.s.copy_logical_slots(self.leader, ['foo']) self.s.copy_logical_slots(self.leader, ['foo'])
+5 -4
View File
@@ -1,14 +1,15 @@
import psycopg2
import subprocess import subprocess
import unittest import unittest
import patroni.psycopg as psycopg
from mock import Mock, PropertyMock, patch, mock_open from mock import Mock, PropertyMock, patch, mock_open
from patroni.scripts import wale_restore from patroni.scripts import wale_restore
from patroni.scripts.wale_restore import WALERestore, main as _main, get_major_version from patroni.scripts.wale_restore import WALERestore, main as _main, get_major_version
from six.moves import builtins from six.moves import builtins
from threading import current_thread from threading import current_thread
from . import MockConnect, psycopg2_connect from . import MockConnect, psycopg_connect
wale_output_header = ( wale_output_header = (
b'name\tlast_modified\t' b'name\tlast_modified\t'
@@ -34,7 +35,7 @@ WALE_TEST_RETRIES = 2
@patch('os.makedirs', Mock(return_value=True)) @patch('os.makedirs', Mock(return_value=True))
@patch('os.path.exists', Mock(return_value=True)) @patch('os.path.exists', Mock(return_value=True))
@patch('os.path.isdir', Mock(return_value=True)) @patch('os.path.isdir', Mock(return_value=True))
@patch('psycopg2.connect', psycopg2_connect) @patch('patroni.psycopg.connect', psycopg_connect)
@patch('subprocess.check_output', Mock(return_value=wale_output)) @patch('subprocess.check_output', Mock(return_value=wale_output))
class TestWALERestore(unittest.TestCase): class TestWALERestore(unittest.TestCase):
@@ -57,7 +58,7 @@ class TestWALERestore(unittest.TestCase):
with patch('subprocess.check_output', Mock(return_value=wale_output.replace(b'167772160', b'1'))): with patch('subprocess.check_output', Mock(return_value=wale_output.replace(b'167772160', b'1'))):
self.assertFalse(self.wale_restore.should_use_s3_to_create_replica()) self.assertFalse(self.wale_restore.should_use_s3_to_create_replica())
with patch('psycopg2.connect', Mock(side_effect=psycopg2.Error("foo"))): with patch('patroni.psycopg.connect', Mock(side_effect=psycopg.Error("foo"))):
save_no_master = self.wale_restore.no_master save_no_master = self.wale_restore.no_master
save_master_connection = self.wale_restore.master_connection save_master_connection = self.wale_restore.master_connection
+10 -4
View File
@@ -6,8 +6,8 @@ from kazoo.client import KazooClient, KazooState
from kazoo.exceptions import NoNodeError, NodeExistsError from kazoo.exceptions import NoNodeError, NodeExistsError
from kazoo.handlers.threading import SequentialThreadingHandler from kazoo.handlers.threading import SequentialThreadingHandler
from kazoo.protocol.states import KeeperState, ZnodeStat from kazoo.protocol.states import KeeperState, ZnodeStat
from mock import Mock, patch from mock import Mock, PropertyMock, patch
from patroni.dcs.zookeeper import Leader, PatroniKazooClient,\ from patroni.dcs.zookeeper import Cluster, Leader, PatroniKazooClient,\
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
@@ -144,7 +144,8 @@ class TestZooKeeper(unittest.TestCase):
@patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient) @patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
def setUp(self): def setUp(self):
self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test', self.zk = ZooKeeper({'hosts': ['localhost:2181'], 'scope': 'test',
'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10}) 'name': 'foo', 'ttl': 30, 'retry_timeout': 10, 'loop_wait': 10,
'set_acls': {'CN=principal2': ['ALL']}})
def test_session_listener(self): def test_session_listener(self):
self.zk.session_listener(KazooState.SUSPENDED) self.zk.session_listener(KazooState.SUSPENDED)
@@ -173,6 +174,8 @@ class TestZooKeeper(unittest.TestCase):
self.assertRaises(ZooKeeperError, self.zk.get_cluster) self.assertRaises(ZooKeeperError, self.zk.get_cluster)
cluster = self.zk.get_cluster(True) cluster = self.zk.get_cluster(True)
self.assertIsInstance(cluster.leader, Leader) self.assertIsInstance(cluster.leader, Leader)
self.zk.status_watcher(None)
self.zk.get_cluster()
self.zk.touch_member({'foo': 'foo'}) self.zk.touch_member({'foo': 'foo'})
self.zk._name = 'bar' self.zk._name = 'bar'
self.zk.status_watcher(None) self.zk.status_watcher(None)
@@ -213,6 +216,7 @@ class TestZooKeeper(unittest.TestCase):
self.zk.touch_member({'retry': 'retry'}) self.zk.touch_member({'retry': 'retry'})
self.zk._fetch_cluster = True self.zk._fetch_cluster = True
self.zk.get_cluster() self.zk.get_cluster()
self.zk.touch_member({'retry': 'retry'})
self.zk.touch_member({'conn_url': 'postgres://repuser:rep-pass@localhost:5434/postgres', self.zk.touch_member({'conn_url': 'postgres://repuser:rep-pass@localhost:5434/postgres',
'api_url': 'http://127.0.0.1:8009/patroni'}) 'api_url': 'http://127.0.0.1:8009/patroni'})
@@ -224,6 +228,7 @@ class TestZooKeeper(unittest.TestCase):
def test_update_leader(self): def test_update_leader(self):
self.assertTrue(self.zk.update_leader(12345)) self.assertTrue(self.zk.update_leader(12345))
@patch.object(Cluster, 'min_version', PropertyMock(return_value=(2, 0)))
def test_write_leader_optime(self): def test_write_leader_optime(self):
self.zk.last_lsn = '0' self.zk.last_lsn = '0'
self.zk.write_leader_optime('1') self.zk.write_leader_optime('1')
@@ -232,6 +237,7 @@ class TestZooKeeper(unittest.TestCase):
with patch.object(MockKazooClient, 'set_async', Mock()): with patch.object(MockKazooClient, 'set_async', Mock()):
self.zk.write_leader_optime('2') self.zk.write_leader_optime('2')
self.zk._base_path = self.zk._base_path.replace('test', 'bla') self.zk._base_path = self.zk._base_path.replace('test', 'bla')
self.zk.get_cluster()
self.zk.write_leader_optime('3') self.zk.write_leader_optime('3')
def test_delete_cluster(self): def test_delete_cluster(self):
@@ -239,7 +245,7 @@ class TestZooKeeper(unittest.TestCase):
def test_watch(self): def test_watch(self):
self.zk.watch(None, 0) self.zk.watch(None, 0)
self.zk.event.isSet = Mock(return_value=True) self.zk.event.is_set = Mock(return_value=True)
self.zk._fetch_status = False self.zk._fetch_status = False
self.zk.watch(None, 0) self.zk.watch(None, 0)