Compare commits

...
30 Commits
Author SHA1 Message Date
Alexander KukushkinandGitHub f2309abc87 Release 2.1.0 (#1998)
* bump version
* update release notes
2021-07-06 10:19:22 +02:00
Christian ClaussandGitHub 75e52226a8 Fix typos discovered by codespell (#1997) 2021-07-06 10:01:30 +02:00
Alexander KukushkinandGitHub 62aa1333cd Implemented allowlist for REST API (#1959)
If configured, only IPs that matching rules would be allowed to call unsafe endpoints.
In addition to that, it is possible to automatically include IPs of members of the cluster to the list.
If neither of the above is configured the old behavior is retained.

Partially address https://github.com/zalando/patroni/issues/1734
2021-07-05 09:43:56 +02:00
Alexander KukushkinandGitHub b7a11232eb Track /status key updates (#1957)
The #1820 introduced a new key in DCS, named /status, which contains the leader optime and maybe flush LSN of logical slots.

In case of etcd3 and raft we should use updates of this key for syncing HA loop across nodes (before we were using /optime/leader).
2021-07-05 09:43:01 +02:00
Alexander KukushkinandGitHub 333d292eb3 Handle DNS issues in Raft implementation (#1960)
- Resolve Node IP for every connection attempt
- Handle exception with connection failures due to failed resolve
- Set PySyncObj DNS Cache timeouts aligned with `loop_wait` and `ttl`

In addition to that,  postpone the leader race for freshly started Raft nodes. It will help with the situation when the leader node was alone and demoted the Postgres and after that, the replica arrives, and quickly takes the leader lock without really performing the leader race.

Close https://github.com/zalando/patroni/issues/1930, https://github.com/zalando/patroni/issues/1931
2021-07-05 09:30:31 +02:00
Alexander KukushkinandGitHub 0ceb59b49d Write prev LSN to before checkpoint to optime if wal_achive=on (#1889)
The #1527 introduced a feature of updating `/optime/leader` with the location of the last checkpoint after the Postgres was shutdown cleanly.

If wal archiving is enabled, Postgres always switching the WAL file before writing the checkpoint shutdown record. Normally it is not an issue, but for databases without too much write activity it could lead to the situation that the visible replication lag becomes equal to the size of a single WAL file. In fact, the previous WAL file is mostly empty and contains only a few records.

Therefore it should be safe to report the LSN of the SWITCH record before the shutdown checkpoint.
In order to do that, Patroni first gets the output of the pg_controldata and based on it calls pg_waldump two times:
* The first call reads the checkpoint record (and verifies that this is really the shutdown checkpoint).
* The next call reads the previous record and in case if it is the 'xlog switch' (for 9.3 and 9.4) or 'SWITCH' (for 9.5+), the LSN
of the SWITCH record is written to the `/optime/leader`.

In case of any mismatch, failure to call pg_waldump or parse its output, the old behavior is retained, i.e. `Latest checkpoint location` from the pg_controldata is used.

Close https://github.com/zalando/patroni/issues/1860
2021-07-05 09:29:39 +02:00
Rok MandeljcandGitHub 96ebf42dc7 Fix problem with PyInstaller-frozen code and dot in the path (#1994)
If `PyInstaller`-frozen application using `patroni` is placed in a location that contains a dot in the path, the `dcs_modules()` function in `patroni.dcs` breaks because `pkgutil.iter_importers()` treats the given path as a package name.

Ref: pyinstaller/pyinstaller#5944

This can be avoided altogether by not passing a path to `iter_importers()`, because `PyInstaller`'s `FrozenImporter` is a singleton and registered as top-level finder.
2021-07-02 08:44:45 +02:00
Florian BütlerandGitHub e2d8a7d086 fix minor typo (#1991)
close #1990
2021-07-02 08:27:17 +02:00
Alexander KukushkinandGitHub 77382e75dc Compatibility with kazoo-2.7+ (#1982)
Old versions of `kazoo` immediately discarded all requests to Zookeeper if the connection is in the `SUSPENDED` state. This is absolutely fine because Patroni is handling retries on its own.
Starting from 2.7, kazoo started queueing requests instead of discarding and as a result, the Patroni HA  loop was getting stuck until the connection to Zookeeper is reestablished, causing no demote of the Postgres.
In order to return to the old behavior we override the `KazooClient._call()` method.

In addition to that, we ensure that the `Postgresql.reset_cluster_info_state()` method is called even if DCS failed (the order of calls was changed in the #1820).

Close https://github.com/zalando/patroni/issues/1981
2021-06-30 09:11:27 +02:00
Alexander KukushkinandGitHub 6616acff58 Postpone writing postgresql.conf when joining running Postgres 12+ (#1956)
When joining already running Postgres, Patroni ensures that config files are set according to expectations.
With recovery parameters converted to GUCs in Postgres v12 it became a little problem, because when the `Postgresql` object is being created it is not yet known where the given replica is supposed to stream from.
It resulted in postgresql.conf first being written without recovery parameters, and on the next run of HA loop Patroni noticing inconsistencies and updating the config one more time.

For Postgres v12 it is not a big issue, but for v13+ it resulted in interruption of streaming replication.
2021-06-30 09:11:12 +02:00
Alexander KukushkinandGitHub f3420e2db5 Compatibility with PostgreSQL 14 (#1926)
PostgreSQL 14 changed the behavior of replicas when certain parameters (like for example `max_connections`) are changed (increased): https://github.com/postgres/postgres/commit/15251c0a.
Instead of immediately exiting Postgres 14 pauses replication and waits for actions from the operator.

Since the `pg_is_wal_replay_paused()` returning `True` is the only indicator of such a change, Patroni on the replica will call the `pg_wal_replay_resume()`, which would cause either continue replication or shutdown (like previously).

So far Patroni was never calling `pg_wal_replay_resume()` on its own, therefore, to remain backward compatible it will call it only for PostgreSQL 14+.
2021-06-25 13:41:45 +02:00
Alexander KukushkinandGitHub 448d703733 Explicitely request cluster version when connecting to etcd via proxy (#1974)
Close https://github.com/zalando/patroni/issues/1971
2021-06-24 08:51:07 +02:00
Arman Jafari TehraniandGitHub e48df9987d Add health check on user defined tags (#1964)
Close #1958
2021-06-23 08:30:10 +02:00
Michael BanckandGitHub 00bf546848 Add scope as label to Prometheus metrics (#1978) 2021-06-22 16:49:48 +02:00
Alexander KukushkinandGitHub f403719bb4 Reduce chattiness of Patroni logs (#1955)
1. When everything goes normal, only one line will be written for every run of HA loop (see examples):
```
INFO: no action. I am (postgresql0) the leader with the lock
INFO: no action. I am a secondary (postgresql1) and following a leader (postgresql0)
```

2. The `does not have lock` became a debug message.
3. The `Lock owner: postgresql0; I am postgresql1` will be shown only when stream doesn't look normal.
2021-06-22 09:13:30 +02:00
Alexander KukushkinandGitHub 03e71b6717 The /leader endpoint returns 200 if node holds the lock (#1917)
Promoting the standby cluster requires updating load-balancer health checks, which is not very convenient and easy to forget.
In order to solve it, we change the behavior of the `/leader` health-check endpoint. It will return 200 without taking into account whether PostgreSQL is running as the primary or the standby_leader.
2021-06-22 08:21:29 +02:00
Alexander KukushkinandGitHub e5bfd4f5ee Compatibility with psycopg2 2.9+ (#1970)
the autocommit = True is ignored in the `with connection` block
2021-06-17 14:29:10 +02:00
Alexander KukushkinandGitHub 2d504a4f0a Copy the logical slot over if advance failed due to the missing WAL (#1946)
It could happen that the replica for some reason is missing the WAL file required by the replication slot.
The nature of this phenomenon is a bit unclear, it might be that the WAL was recycled short before we copied the slot file, but, we still need a solution to this problem. If the `pg_replication_slot_advance()` fails with the `UndefinedFile` exception (requested WAL segment pg_wal/... has already been removed), the logical slot on the replica must be recreated.
2021-06-02 16:57:13 +02:00
Alexander KukushkinandGitHub eaa98e71e3 Fix bug with unix socket connections (#1933)
When the unix_socket_directories is not known Patroni was immediately going back to tcp connection via the localhost.

The bug was introduced in https://github.com/zalando/patroni/pull/1865
2021-05-10 09:53:25 +02:00
Alexander KukushkinandGitHub 99626a07f2 Fix issues with raft traffic encryption (#1919)
and run raft behave tests with encryption enabled.

Using the new `pysyncobj` release allowed us to get rid of a lot of hacks with accessing private properties and methods of the parent class and reduce the size of the `raft.py`.

Close https://github.com/zalando/patroni/issues/1746
2021-04-30 11:28:41 +02:00
Tommy LiandGitHub 294bb43bf1 Add Patronis default postgres configs to the sample yml files (#1909)
This adds the default Postgres settings enforced by Patroni to the `postgres{n}.yml` files provided in the repo. The documentation does call out the defaults that Patroni will set, but it can be missed if you download postgres0.yml and use that as a starting point. Hopefully the extra commented out configs serve as a visual cue to save the next person from the same mistake :)
2021-04-20 09:45:43 +02:00
Takuya NandGitHub b52f458a93 Update link for tests status on GH Actions (#1903)
Follows #1778
2021-04-20 09:43:37 +02:00
melrifaandGitHub 6d6b504cb8 Add support for patroni replication user socket connection (#1865)
Close #1866
2021-04-20 09:43:05 +02:00
Alexander KukushkinandGitHub 3ae459c6d5 Get rid of false warning about invalid parameter (#1908)
Despite all recovery parameters became GUCs in PostgreSQL 12, there are very good reasons to keep them separated in the Patroni internals.

While implementing PostgreSQL parameters validation in #1674 one little oversight occurred. The parameters validation happens before the recovery parameters are skipped from the list, which produces a false warning.

Close https://github.com/zalando/patroni/issues/1907
2021-04-20 09:40:22 +02:00
Alexander KukushkinandGitHub 51cda9fb6e Fix excessive HA loop runs with Zookeeper (#1875)
1. Commit 04b9fb9dd4 introduced additional conditions for updating cached version of the leader optime. It was required for implementing health-checks based on replication lag in the https://github.com/zalando/patroni/pull/1599.
  What in fact was forgotten, the event should be cleared after the new value of the optime was fetched. Not doing so results in running the HA loop more frequently than is required.
  
2. Don't watch for sync members.
  The watch for sync member(s) was introduced in order to give a signal to the leader that one of the members set the `nosync` tag to true.
  Since that time we have got a few more conditions that should be notified about, therefore instead of watching for all members of the cluster every cluster member checks whether the condition is met, and instead of updating ZNode performs delete+create.
  Since every member is already watching for new ZNodes to be created inside the $scope/members/, they automatically get notified about important changes, and therefore watching for sync members is redundant.

3. In addition to that, slightly increase watch timeout, it will keep HA loops in sync across all nodes in the cluster.
Close https://github.com/zalando/patroni/pull/1873
2021-03-29 08:08:26 +02:00
Michael TodorovicandGitHub 3dbe6a542a fix: reload api if certificate changed on disk (#1887)
This PR fixes #1886.

We get the certificate serial number on server startup and store it in `api.__ssl_serial_number`
On reload, we get again the serial number from disk and compare it to the one stored in `api.__ssl_serial_number`: if different, then the api will be reloaded (even if the config file didn't change)
2021-03-29 08:07:48 +02:00
Kostiantyn NemchenkoandGitHub e15b809ed3 Don't create pgpass dir if kerberos auth is used (#1888)
Fixes #1551
2021-03-29 08:07:02 +02:00
Alexander KukushkinandGitHub 9edbe7e3f7 Fix little issues with custom bootstrap (#1891)
1. Set hot_standby=off only when we do PITR
2. Restart postgres after PITR is done to avoid warnings
3. Address invalid config issue https://github.com/zalando/patroni/issues/1870#issuecomment-800088643
2021-03-29 08:06:12 +02:00
Alexander KukushkinandGitHub c7173aadd7 Failover logical slots (#1820)
Effectively, this PR consists of a few changes:

1. The easy part:
  In case of permanent logical slots are defined in the global configuration, Patroni on the primary will not only create them, but also periodically update DCS with the current values of `confirmed_flush_lsn` for all these slots.
  In order to reduce the number of interactions with DCS the new `/status` key was introduced. It will contain the json object with `optime` and `slots` keys. For backward compatibility the `/optime/leader` will be updated if there are members with old Patroni in the cluster.

2. The tricky part:
  On replicas that are eligible for a failover, Patroni creates the logical replication slot by copying the slot file from the primary and restarting the replica. In order to copy the slot file Patroni opens a connection to the primary with `rewind` or `superuser` credentials and calls `pg_read_binary_file()`  function.
  When the logical slot already exists on the replica Patroni periodically calls `pg_replication_slot_advance()` function, which allows moving the slot forward.

3. Additional requirements:
  In order to ensure that primary doesn't cleanup tuples from pg_catalog that are required for logical decoding, Patroni enables `hot_standby_feedback` on replicas with logical slots and on cascading replicas if they are used for streaming by replicas with logical slots.

4. When logical slots are copied from to the replica there is a timeframe when it could be not safe to use them after promotion. Right now there is no protection from promoting such a replica. But, Patroni will show the warning with names of the slots that might be not safe to use.

Compatibility.
The `pg_replication_slot_advance()` function is only available starting from PostgreSQL 11. For older Postgres versions Patroni will refuse to create the logical slot on the primary.

The old "permanent slots" feature, which creates logical slots right after promotion and before allowing connections, was removed.

Close: https://github.com/zalando/patroni/issues/1749
2021-03-25 16:18:23 +01:00
Mark MercadoandGitHub 09f2f579d7 Quick attempt at Prometheus (#1848)
Close https://github.com/zalando/patroni/issues/318
2021-03-04 12:37:29 +01:00
61 changed files with 2003 additions and 739 deletions
+6 -2
View File
@@ -22,7 +22,7 @@ def install_requirements(what):
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 != '':
extras = {e for e, v in EXTRAS_REQUIRE.items() if v and r.startswith(v[0])} extras = {e for e, v in EXTRAS_REQUIRE.items() if v and any(r.startswith(x) for x in v)}
if not extras or what == 'all' or what in extras: if not extras or what == 'all' or what in extras:
requirements.append(r) requirements.append(r)
@@ -33,13 +33,17 @@ def install_requirements(what):
def install_packages(what): def install_packages(what):
from mapping import versions
packages = { packages = {
'zookeeper': ['zookeeper', 'zookeeper-bin', 'zookeeperd'], 'zookeeper': ['zookeeper', 'zookeeper-bin', 'zookeeperd'],
'consul': ['consul'], 'consul': ['consul'],
} }
packages['exhibitor'] = packages['zookeeper'] packages['exhibitor'] = packages['zookeeper']
packages = packages.get(what, []) packages = packages.get(what, [])
ver = str({'etcd': '9.6', 'etcd3': '9.6', 'consul': 10, 'exhibitor': 11, 'kubernetes': 12, 'raft': 13}.get(what)) ver = versions.get(what)
subprocess.call(['sudo', 'sed', '-i', 's/pgdg main.*$/pgdg main {0}/'.format(ver),
'/etc/apt/sources.list.d/pgdg.list'])
subprocess.call(['sudo', 'apt-get', 'update', '-y']) subprocess.call(['sudo', 'apt-get', 'update', '-y'])
return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev', 'wget'] + packages) return subprocess.call(['sudo', 'apt-get', 'install', '-y', 'postgresql-' + ver, 'expect-dev', 'wget'] + packages)
+1
View File
@@ -0,0 +1 @@
versions = {'etcd': '9.6', 'etcd3': '14', 'consul': '13', 'exhibitor': '12', 'raft': '11', 'kubernetes': '14'}
+6 -3
View File
@@ -23,18 +23,21 @@ def main():
env = os.environ.copy() env = os.environ.copy()
if sys.platform.startswith('linux'): if sys.platform.startswith('linux'):
version = {'etcd': '9.6', 'etcd3': '9.6', 'consul': 10, 'exhibitor': 11, 'kubernetes': 12, 'raft': 13}.get(what) from mapping import versions
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', '600', 'unbuffer']
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'))
if sys.platform == 'darwin': if sys.platform == 'darwin':
path += ':.' path += ':.'
unbuffer = [] args = unbuffer = []
env['PATH'] = path + os.pathsep + env['PATH'] env['PATH'] = path + os.pathsep + env['PATH']
env['DCS'] = what env['DCS'] = what
ret = subprocess.call(unbuffer + [sys.executable, '-m', 'behave'], env=env) ret = subprocess.call(unbuffer + [sys.executable, '-m', 'behave'] + args, env=env)
if ret != 0: if ret != 0:
if subprocess.call('grep . features/output/*_failed/*postgres?.*', shell=True) != 0: if subprocess.call('grep . features/output/*_failed/*postgres?.*', shell=True) != 0:
+2
View File
@@ -115,6 +115,8 @@ jobs:
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- 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'
- 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
+5 -5
View File
@@ -1,4 +1,4 @@
|Build Status| |Coverage Status| |Tests Status| |Coverage Status|
Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul Patroni: A Template for PostgreSQL HA with ZooKeeper, etcd or Consul
-------------------------------------------------------------------- --------------------------------------------------------------------
@@ -119,7 +119,7 @@ For example, the command in order to install Patroni together with dependencies
pip install patroni[etcd,aws] pip install patroni[etcd,aws]
Note that external tools to call in the replica creation or custom bootstap scripts (i.e. WAL-E) should be installed independently of Patroni. Note that external tools to call in the replica creation or custom bootstrap scripts (i.e. WAL-E) should be installed independently of Patroni.
======================= =======================
Running and Configuring Running and Configuring
@@ -171,7 +171,7 @@ Applications Should Not Use Superusers
When connecting from an application, always use a non-superuser. Patroni requires access to the database to function properly. By using a superuser from an application, you can potentially use the entire connection pool, including the connections reserved for superusers, with the ``superuser_reserved_connections`` setting. If Patroni cannot access the Primary because the connection pool is full, behavior will be undesirable. When connecting from an application, always use a non-superuser. Patroni requires access to the database to function properly. By using a superuser from an application, you can potentially use the entire connection pool, including the connections reserved for superusers, with the ``superuser_reserved_connections`` setting. If Patroni cannot access the Primary because the connection pool is full, behavior will be undesirable.
.. |Build Status| image:: https://travis-ci.org/zalando/patroni.svg?branch=master .. |Tests Status| image:: https://github.com/zalando/patroni/actions/workflows/tests.yaml/badge.svg
:target: https://travis-ci.org/zalando/patroni :target: https://github.com/zalando/patroni/actions/workflows/tests.yaml?query=branch%3Amaster
.. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master .. |Coverage Status| image:: https://coveralls.io/repos/zalando/patroni/badge.svg?branch=master
:target: https://coveralls.io/r/zalando/patroni?branch=master :target: https://coveralls.io/github/zalando/patroni?branch=master
+2
View File
@@ -166,6 +166,8 @@ REST API
- **PATRONI\_RESTAPI\_CAFILE**: Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs. - **PATRONI\_RESTAPI\_CAFILE**: Specifies the file with the CA_BUNDLE with certificates of trusted CAs to use while verifying client certs.
- **PATRONI\_RESTAPI\_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") - **PATRONI\_RESTAPI\_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")
- **PATRONI\_RESTAPI\_VERIFY\_CLIENT**: ``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. - **PATRONI\_RESTAPI\_VERIFY\_CLIENT**: ``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.
- **PATRONI\_RESTAPI\_ALLOWLIST**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
- **PATRONI\_RESTAPI\_ALLOWLIST\_INCLUDE\_MEMBERS**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
- **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response. - **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response.
- **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``. - **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
+1 -1
View File
@@ -166,7 +166,7 @@ When connecting from an application, always use a non-superuser. Patroni require
Testing Your HA Solution Testing Your HA Solution
-------------------------------------- --------------------------------------
Testing an HA solution is a time consuming process, with many variables. This is particularly true considering a cross-platform application. You need a trained system administrator or a consultant to do this work. It is not something we can cover in depth in the documentaiton. Testing an HA solution is a time consuming process, with many variables. This is particularly true considering a cross-platform application. You need a trained system administrator or a consultant to do this work. It is not something we can cover in depth in the documentation.
That said, here are some pieces of your infrastructure you should be sure to test: That said, here are some pieces of your infrastructure you should be sure to test:
+16 -2
View File
@@ -34,7 +34,7 @@ Dynamic configuration is stored in the DCS (Distributed Configuration Store) and
- **restore\_command**: command to restore WAL records from the remote master to standby leader, can be different from the list defined in :ref:`postgresql_settings` - **restore\_command**: command to restore WAL records from the remote master to standby leader, can be different from the list defined in :ref:`postgresql_settings`
- **archive\_cleanup\_command**: cleanup command for standby leader - **archive\_cleanup\_command**: cleanup command for standby leader
- **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader - **recovery\_min\_apply\_delay**: how long to wait before actually apply WAL records on a standby leader
- **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. Patroni will try to create slots before opening connections to the cluster. - **slots**: define permanent replication slots. These slots will be preserved during switchover/failover. The logical slots are copied from the primary to a standby with restart, and after that their position advanced every **loop_wait** seconds (if necessary). Copying logical slot files performed via ``libpq`` connection and using either rewind or superuser credentials (see **postgresql.authentication** section). There is always a chance that the logical slot position on the replica is a bit behind the former primary, therefore application should be prepared that some messages could be received the second time after the failover. The easiest way of doing so - tracking ``confirmed_flush_lsn``. Enabling permanent logical replication slots requires **postgresql.use_slots** to be set and will also automatically enable the ``hot_standby_feedback``. Since the failover of logical replication slots is unsafe on PostgreSQL 9.6 and older and PostgreSQL version 10 is missing some important functions, the feature only works with PostgreSQL 11+.
- **my_slot_name**: the name of replication slot. If the permanent slot name matches with the name of the current primary it will not be created. Everything else is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots. - **my_slot_name**: the name of replication slot. If the permanent slot name matches with the name of the current primary it will not be created. Everything else is the responsibility of the operator to make sure that there are no clashes in names between replication slots automatically created by Patroni for members and permanent replication slots.
- **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``. - **type**: slot type. Could be ``physical`` or ``logical``. If the slot is logical, you have to additionally define ``database`` and ``plugin``.
- **database**: the database name where logical slots should be created. - **database**: the database name where logical slots should be created.
@@ -228,7 +228,7 @@ Raft
- Q: Where to get the ``syncobj_admin`` utility? - Q: Where to get the ``syncobj_admin`` utility?
A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependancy. A: It is installed together with ``pysyncobj`` module (python RAFT implementation), which is Patroni dependency.
- Q: it is possible to run Patroni node without adding in to the consensus? - Q: it is possible to run Patroni node without adding in to the consensus?
@@ -293,6 +293,7 @@ PostgreSQL
- **bin\_dir**: Path to PostgreSQL binaries (pg_ctl, pg_rewind, pg_basebackup, postgres). The default value is an empty string meaning that PATH environment variable will be used to find the executables. - **bin\_dir**: Path to PostgreSQL binaries (pg_ctl, pg_rewind, pg_basebackup, postgres). The default value is an empty string meaning that PATH environment variable will be used to find the executables.
- **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node. - **listen**: IP address + port that Postgres listens to; must be accessible from other nodes in the cluster, if you're using streaming replication. Multiple comma-separated addresses are permitted, as long as the port component is appended after to the last one with a colon, i.e. ``listen: 127.0.0.1,127.0.0.2:5432``. Patroni will use the first address from this list to establish local connections to the PostgreSQL node.
- **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters. - **use\_unix\_socket**: specifies that Patroni should prefer to use unix sockets to connect to the cluster. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
- **use\_unix\_socket\_repl**: specifies that Patroni should prefer to use unix sockets for replication user cluster connection. Default value is ``false``. If ``unix_socket_directories`` is defined, Patroni will use the first suitable value from it to connect to the cluster and fallback to tcp if nothing is suitable. If ``unix_socket_directories`` is not specified in ``postgresql.parameters``, Patroni will assume that the default value should be used and omit ``host`` from the connection parameters.
- **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni. - **pgpass**: path to the `.pgpass <https://www.postgresql.org/docs/current/static/libpq-pgpass.html>`__ password file. Patroni creates this file before executing pg\_basebackup, the post_init script and under some other circumstances. The location must be writable by Patroni.
- **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower. - **recovery\_conf**: additional configuration settings written to recovery.conf when configuring follower.
- **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <dynamic_configuration>` for details. - **custom\_conf** : path to an optional custom ``postgresql.conf`` file, that will be used in place of ``postgresql.base.conf``. The file must exist on all cluster nodes, be readable by PostgreSQL and will be included from its location on the real ``postgresql.conf``. Note that Patroni will not monitor this file for changes, nor backup it. However, its settings can still be overridden by Patroni's own configuration facilities - see :ref:`dynamic configuration <dynamic_configuration>` for details.
@@ -326,6 +327,8 @@ REST API
- **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.
- **allowlist**: (optional): Specifies the set of hosts that are allowed to call unsafe REST API endpoints. The single element could be a host name, an IP address or a network address using CIDR notation. By default ``allow all`` is used. In case if ``allowlist`` or ``allowlist_include_members`` are set, anything that is not included is rejected.
- **allowlist\_include\_members**: (optional): If set to ``true`` it allows accessing unsafe REST API endpoints from other cluster members registered in DCS (IP address or hostname is taken from the members ``api_url``). Be careful, it might happen that OS will use a different IP for outgoing connections.
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response. - **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``. - **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
@@ -365,6 +368,8 @@ Watchdog
- **device**: Path to watchdog device. Defaults to ``/dev/watchdog``. - **device**: Path to watchdog device. Defaults to ``/dev/watchdog``.
- **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration. - **safety_margin**: Number of seconds of safety margin between watchdog triggering and leader key expiration.
.. _tags_settings:
Tags Tags
---- ----
- **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false`` - **nofailover**: ``true`` or ``false``, controls whether this node is allowed to participate in the leader race and become a leader. Defaults to ``false``
@@ -372,3 +377,12 @@ Tags
- **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``. - **noloadbalance**: ``true`` or ``false``. If set to ``true`` the node will return HTTP Status Code 503 for the ``GET /replica`` REST API health-check and therefore will be excluded from the load-balancing. Defaults to ``false``.
- **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication. - **replicatefrom**: The IP address/hostname of another replica. Used to support cascading replication.
- **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica. - **nosync**: ``true`` or ``false``. If set to ``true`` the node will never be selected as a synchronous replica.
In addition to these predefined tags, you can also add your own ones:
- **key1**: ``true``
- **key2**: ``false``
- **key3**: ``1.4``
- **key4**: ``"RandomString"``
Tags are visible in the :ref:`REST API <rest_api>` and ``patronictl list`` You can also check for an instance health using these tags. If the tag isn't defined for an instance, or if the respective value doesn't match the querying value, it will return HTTP Status Code 503.
+103 -11
View File
@@ -3,6 +3,98 @@
Release notes Release notes
============= =============
Version 2.1.0
-------------
This version adds compatibility with PostgreSQL v14, makes logical replication slots to survive failover/switchover, implements support of allowlist for REST API, and also reducing the number of logs to one line per heart-beat.
**New features**
- Compatibility with PostgreSQL v14 (Alexander Kukushkin)
Unpause WAL replay if Patroni is not in a "pause" mode itself. It could be "paused" due to the change of certain parameters like for example ``max_connections`` on the primary.
- Failover logical slots (Alexander)
Make logical replication slots survive failover/switchover on PostgreSQL v11+. The replication slot if copied from the primary to the replica with restart and later the `pg_replication_slot_advance() <https://www.postgresql.org/docs/11/functions-admin.html#id-1.5.8.31.8.5.2.2.8.1.1>`__ function is used to move it forward. As a result, the slot will already exist before the failover and no events should be lost, but, there is a chance that some events could be delivered more than once.
- Implemented allowlist for Patroni REST API (Alexander)
If configured, only IP's that matching rules would be allowed to call unsafe endpoints. In addition to that, it is possible to automatically include IP's of members of the cluster to the list.
- Added support of replication connections via unix socket (Mohamad El-Rifai)
Previously Patroni was always using TCP for replication connection what could cause some issues with SSL verification. Using unix sockets allows exempt replication user from SSL verification.
- Health check on user-defined tags (Arman Jafari Tehrani)
Along with :ref:`predefined tags: <tags_settings>` it is possible to specify any number of custom tags that become visible in the ``patronictl list`` output and in the REST API. From now on it is possible to use custom tags in health checks.
- Added Prometheus ``/metrics`` endpoint (Mark Mercado, Michael Banck)
The endpoint exposing the same metrics as ``/patroni``.
- Reduced chattiness of Patroni logs (Alexander)
When everything goes normal, only one line will be written for every run of HA loop.
**Breaking chances**
- The old ``permanent logical replication slots`` feature will no longer work with PostgreSQL v10 and older (Alexander)
The strategy of creating the logical slots after performing a promotion can't guaranty that no logical events are lost and therefore disabled.
- The ``/leader`` always endpoint returns 200 if the node holds the lock (Alexander)
Promoting the standby cluster requires updating load-balancer health checks, which is not very convenient and easy to forget. To solve it, we change the behavior of the ``/leader`` health check endpoint. It will return 200 without taking into account whether the cluster is normal or the ``standby_cluster``.
**Improvements in Raft support**
- Reliable support of Raft traffic encryption (Alexander)
Due to the different issues in the ``PySyncObj`` the encryption support was very unstable
- Handle DNS issues in Raft implementation (Alexander)
If ``self_addr`` and/or ``partner_addrs`` are configured using the DNS name instead of IP's the ``PySyncObj`` was effectively doing resolve only once when the object is created. It was causing problems when the same node was coming back online with a different IP.
**Stability improvements**
- Compatibility with ``psycopg2-2.9+`` (Alexander)
In ``psycopg2`` the ``autocommit = True`` is ignored in the ``with connection`` block, which breaks replication protocol connections.
- Fix excessive HA loop runs with Zookeeper (Alexander)
Update of member ZNodes was causing a chain reaction and resulted in running the HA loops multiple times in a row.
- Reload if REST API certificate is changed on disk (Michael Todorovic)
If the REST API certificate file was updated in place Patroni didn't perform a reload.
- Don't create pgpass dir if kerberos auth is used (Kostiantyn Nemchenko)
Kerberos and password authentication are mutually exclusive.
- Fixed little issues with custom bootstrap (Alexander)
Start Postgres with ``hot_standby=off`` only when we do a PITR and restart it after PITR is done.
**Bugfixes**
- Compatibility with ``kazoo-2.7+`` (Alexander)
Since Patroni is handling retries on its own, it is relying on the old behavior of ``kazoo`` that requests to a Zookeeper cluster are immediately discarded when there are no connections available.
- Explicitly request the version of Etcd v3 cluster when it is known that we are connecting via proxy (Alexander)
Patroni is working with Etcd v3 cluster via gPRC-gateway and it depending on the cluster version different endpoints (``/v3``, ``/v3beta``, or ``/v3alpha``) must be used. The version was resolved only together with the cluster topology, but since the latter was never done when connecting via proxy.
Version 2.0.2 Version 2.0.2
------------- -------------
@@ -682,7 +774,7 @@ Version 1.6.1
- Some improvements in logging infrastructure (Alexander Kukushkin) - Some improvements in logging infrastructure (Alexander Kukushkin)
Previously threre was a possibility to loose the last few log lines on shutdown because the logging thread was a ``daemon`` thread. Previously there was a possibility to loose the last few log lines on shutdown because the logging thread was a ``daemon`` thread.
- Use ``spawn`` multiprocessing start method on python 3.4+ (Maciej Kowalczyk) - Use ``spawn`` multiprocessing start method on python 3.4+ (Maciej Kowalczyk)
@@ -732,7 +824,7 @@ Version 1.6.1
If the method is executed from the REST API thread, it requires a separate cursor object to be created. If the method is executed from the REST API thread, it requires a separate cursor object to be created.
- Fix the problem of not promoting the sync standby that had a name contaning upper case letters (Alexander Kukushkin) - Fix the problem of not promoting the sync standby that had a name containing upper case letters (Alexander Kukushkin)
We converted the name to the lower case because Postgres was doing the same while comparing the ``application_name`` with the value in ``synchronous_standby_names``. We converted the name to the lower case because Postgres was doing the same while comparing the ``application_name`` with the value in ``synchronous_standby_names``.
@@ -1008,7 +1100,7 @@ Compatibility and bugfix release.
- Fix broken compatibility with postgres 9.3 (Alexander) - Fix broken compatibility with postgres 9.3 (Alexander)
When opening a replication connection we should specify replication=1, beacuse 9.3 does not understand replication='database' When opening a replication connection we should specify replication=1, because 9.3 does not understand replication='database'
- Make sure we refresh Consul session at least once per HA loop and improve handling of consul sessions exceptions (Alexander) - Make sure we refresh Consul session at least once per HA loop and improve handling of consul sessions exceptions (Alexander)
@@ -1093,7 +1185,7 @@ This version enables Patroni HA cluster to operate in a standby mode, introduces
- Immediately reserve the WAL position upon creation of the replication slot (Alexander Kukushkin) - Immediately reserve the WAL position upon creation of the replication slot (Alexander Kukushkin)
Starting from 9.6, `pg_create_physical_replication_slot` function provides an additional boolean parameter `immediately_reserve`. When it is set to `false`, which is also the default, the slot doesn't reserve the WAL position until it receives the first client connection, potentially losing some segments required by the client in a time window between the slot creation and the intiial client connection. Starting from 9.6, `pg_create_physical_replication_slot` function provides an additional boolean parameter `immediately_reserve`. When it is set to `false`, which is also the default, the slot doesn't reserve the WAL position until it receives the first client connection, potentially losing some segments required by the client in a time window between the slot creation and the initial client connection.
- Fix bug in strict synchronous replication (Alexander Kukushkin) - Fix bug in strict synchronous replication (Alexander Kukushkin)
@@ -1333,7 +1425,7 @@ This version adds support for using Kubernetes as a DCS, allowing to run Patroni
**Upgrade notice** **Upgrade notice**
Installing Patroni via pip will no longer bring in dependencies for (such as libraries for Etcd, Zookeper, Consul or Kubernetes, or support for AWS). In order to enable them one need to list them in pip install command explicitely, for instance `pip install patroni[etcd,kubernetes]`. Installing Patroni via pip will no longer bring in dependencies for (such as libraries for Etcd, Zookeper, Consul or Kubernetes, or support for AWS). In order to enable them one need to list them in pip install command explicitly, for instance `pip install patroni[etcd,kubernetes]`.
**Kubernetes support** **Kubernetes support**
@@ -1352,7 +1444,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
- Remove leader key on shutdown only when we have the lock (Ants) - Remove leader key on shutdown only when we have the lock (Ants)
Unconditional removal was generating unnecessary and missleading exceptions. Unconditional removal was generating unnecessary and misleading exceptions.
**Improvements in patronictl** **Improvements in patronictl**
@@ -1383,7 +1475,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
- Alter the behavior of ``patronictl failover`` (Alexander) - Alter the behavior of ``patronictl failover`` (Alexander)
It will work even if there is no leader, but in that case you will have to explicitely specify a node which should become the new leader. It will work even if there is no leader, but in that case you will have to explicitly specify a node which should become the new leader.
**Expose information about timeline and history** **Expose information about timeline and history**
@@ -1399,7 +1491,7 @@ In addition to using Endpoints, Patroni supports ConfigMaps. You can find more i
- Add new /sync and /async endpoints (Alexander, Oleksii Kliukin) - Add new /sync and /async endpoints (Alexander, Oleksii Kliukin)
Those endpoints (also accessible as /synchronous and /asynchronous) return 200 only for synchronous and asynchornous replicas correspondingly (exclusing those marked as `noloadbalance`). Those endpoints (also accessible as /synchronous and /asynchronous) return 200 only for synchronous and asynchronous replicas correspondingly (exclusing those marked as `noloadbalance`).
**Allow multiple hosts for Etcd** **Allow multiple hosts for Etcd**
@@ -1487,7 +1579,7 @@ Version 1.3.4
- Pass the consul token as a header (Andrew Colin Kissa) - Pass the consul token as a header (Andrew Colin Kissa)
Headers are now the prefered way to pass the token to the consul `API <https://www.consul.io/api/index.html#authentication>`__. Headers are now the preferred way to pass the token to the consul `API <https://www.consul.io/api/index.html#authentication>`__.
- Advanced configuration for Consul (Alexander Kukushkin) - Advanced configuration for Consul (Alexander Kukushkin)
@@ -1805,7 +1897,7 @@ In addition, patronictl supports new ``pause`` and ``resume`` commands to toggle
Originally, ping_timeout and connect_timeout values were calculated from the negotiated session timeout. Patroni loop_wait was not taken into account. As Originally, ping_timeout and connect_timeout values were calculated from the negotiated session timeout. Patroni loop_wait was not taken into account. As
a result, a single retry could take more time than the session timeout, forcing Patroni to release the lock and demote. a result, a single retry could take more time than the session timeout, forcing Patroni to release the lock and demote.
This change set ping and connect timeout to half of the value of loop_wait, speeding up detection of connection issues and leaving enough time to retry the connection attempt before loosing the lock. This change set ping and connect timeout to half of the value of loop_wait, speeding up detection of connection issues and leaving enough time to retry the connection attempt before losing the lock.
- Update Etcd topology only after original request succeed (Alexander) - Update Etcd topology only after original request succeed (Alexander)
@@ -1883,7 +1975,7 @@ When upgrading from v0.90 or below, always upgrade all replicas before the maste
See the :ref:`dynamic configuration <dynamic_configuration>` for the details on which parameters can be changed and the order of processing difference configuration sources. See the :ref:`dynamic configuration <dynamic_configuration>` for the details on which parameters can be changed and the order of processing difference configuration sources.
The configuration file format *has changed* since the v0.90. Patroni is still compatible with the old configuration files, but in order to take advantage of the bootstrap parameters one needs to change it. Users are encourage to update them by referring to the :ref:`dynamic configuraton documentation page <dynamic_configuration>`. The configuration file format *has changed* since the v0.90. Patroni is still compatible with the old configuration files, but in order to take advantage of the bootstrap parameters one needs to change it. Users are encourage to update them by referring to the :ref:`dynamic configuration documentation page <dynamic_configuration>`.
**More flexible configuration*** **More flexible configuration***
+17 -5
View File
@@ -9,14 +9,17 @@ Health check endpoints
---------------------- ----------------------
For all health check ``GET`` requests Patroni returns a JSON document with the status of the node, along with the HTTP status code. If you don't want or don't need the JSON document, you might consider using the ``OPTIONS`` method instead of ``GET``. For all health check ``GET`` requests Patroni returns a JSON document with the status of the node, along with the HTTP status code. If you don't want or don't need the JSON document, you might consider using the ``OPTIONS`` method instead of ``GET``.
- The following requests to Patroni REST API will return HTTP status code **200** only when the Patroni node is running as the leader: - The following requests to Patroni REST API will return HTTP status code **200** only when the Patroni node is running as the primary with leader lock:
- ``GET /`` - ``GET /``
- ``GET /master`` - ``GET /master``
- ``GET /leader``
- ``GET /primary`` - ``GET /primary``
- ``GET /read-write`` - ``GET /read-write``
- ``GET /standby-leader``: returns HTTP status code **200** only when the Patroni node is running as the leader in a :ref:`standby cluster <standby_cluster>`.
- ``GET /leader``: returns HTTP status code **200** when the Patroni node has the leader lock. The major difference from the two previous endpoints is that it doesn't take into account whether PostgreSQL is running as the ``primary`` or the ``standby_leader``.
- ``GET /replica``: replica health check endpoint. It returns HTTP status code **200** only when the Patroni node is in the state ``running``, the role is ``replica`` and ``noloadbalance`` tag is not set. - ``GET /replica``: replica health check endpoint. It returns HTTP status code **200** only when the Patroni node is in the state ``running``, the role is ``replica`` and ``noloadbalance`` tag is not set.
- ``GET /replica?lag=<max-lag>``: replica check endpoint. In addition to checks from ``replica``, it also checks replication latency and returns status code **200** only when it is below specified value. The key cluster.last_leader_operation from DCS is used for Leader wal position and compute latency on replica for performance reasons. max-lag can be specified in bytes (integer) or in human readable values, for e.g. 16kB, 64MB, 1GB. - ``GET /replica?lag=<max-lag>``: replica check endpoint. In addition to checks from ``replica``, it also checks replication latency and returns status code **200** only when it is below specified value. The key cluster.last_leader_operation from DCS is used for Leader wal position and compute latency on replica for performance reasons. max-lag can be specified in bytes (integer) or in human readable values, for e.g. 16kB, 64MB, 1GB.
@@ -26,9 +29,18 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
- ``GET /replica?lag=10MB`` - ``GET /replica?lag=10MB``
- ``GET /replica?lag=1GB`` - ``GET /replica?lag=1GB``
- ``GET /read-only``: like the above endpoint, but also includes the primary. - ``GET /replica?tag_key1=value1&tag_key2=value2``: replica check endpoint. In addition, It will also check for user defined tags ``key1`` and ``key2`` and their respective values in the **tags** section of the yaml configuration management. If the tag isn't defined for an instance, or if the value in the yaml configuration doesn't match the querying value, it will return HTTP Status Code 503.
- ``GET /standby-leader``: returns HTTP status code **200** only when the Patroni node is running as the leader in a :ref:`standby cluster <standby_cluster>`. In the following requests, since we are checking for the leader or standby-leader status, Patroni doesn't apply any of the user defined tags and they will be ignored.
- ``GET /?tag_key1=value1&tag_key2=value2``
- ``GET /master?tag_key1=value1&tag_key2=value2``
- ``GET /leader?tag_key1=value1&tag_key2=value2``
- ``GET /primary?tag_key1=value1&tag_key2=value2``
- ``GET /read-write?tag_key1=value1&tag_key2=value2``
- ``GET /standby_leader?tag_key1=value1&tag_key2=value2``
- ``GET /standby-leader?tag_key1=value1&tag_key2=value2``
- ``GET /read-only``: like the above endpoint, but also includes the primary.
- ``GET /synchronous`` or ``GET /sync``: returns HTTP status code **200** only when the Patroni node is running as a synchronous standby. - ``GET /synchronous`` or ``GET /sync``: returns HTTP status code **200** only when the Patroni node is running as a synchronous standby.
@@ -45,7 +57,7 @@ For all health check ``GET`` requests Patroni returns a JSON document with the s
- ``GET /liveness``: always returns HTTP status code **200** what only indicates that Patroni is running. Could be used for ``livenessProbe``. - ``GET /liveness``: always returns HTTP status code **200** what only indicates that Patroni is running. Could be used for ``livenessProbe``.
- ``GET /readiness``: returns HTTP status code **200** when the Patroni node is running as the leader or when PostgreSQL is up and running. The endpoint could be used for ``readinessProbe`` when it is not possible to use Kubenetes endpoints for leader elections (OpenShift). - ``GET /readiness``: returns HTTP status code **200** when the Patroni node is running as the leader or when PostgreSQL is up and running. The endpoint could be used for ``readinessProbe`` when it is not possible to use Kubernetes endpoints for leader elections (OpenShift).
Both, ``readiness`` and ``liveness`` endpoints are very light-weight and not executing any SQL. Probes should be configured in such a way that they start failing about time when the leader key is expiring. With the default value of ``ttl``, which is ``30s`` example probes would look like: Both, ``readiness`` and ``liveness`` endpoints are very light-weight and not executing any SQL. Probes should be configured in such a way that they start failing about time when the leader key is expiring. With the default value of ``ttl``, which is ``30s`` example probes would look like:
+2 -2
View File
@@ -29,7 +29,7 @@ Feature: basic replication
Then I receive a response code 200 Then I receive a response code 200
Scenario: check stuck sync replica Scenario: check stuck sync replica
Given I issue a PATCH request to http://127.0.0.1:8008/config with {"maximum_lag_on_syncnode": 15000000, "postgresql": {"parameters": {"synchronous_commit": "remote_apply"}}} Given I issue a PATCH request to http://127.0.0.1:8008/config with {"pause": true, "maximum_lag_on_syncnode": 15000000, "postgresql": {"parameters": {"synchronous_commit": "remote_apply"}}}
Then I receive a response code 200 Then I receive a response code 200
And I create table on postgres0 And I create table on postgres0
And table mytest is present on postgres1 after 2 seconds And table mytest is present on postgres1 after 2 seconds
@@ -43,7 +43,7 @@ Feature: basic replication
Then I receive a response code 200 Then I receive a response code 200
When I issue a GET request to http://127.0.0.1:8010/async When I issue a GET request to http://127.0.0.1:8010/async
Then I receive a response code 200 Then I receive a response code 200
When I issue a PATCH request to http://127.0.0.1:8008/config with {"maximum_lag_on_syncnode": -1, "postgresql": {"parameters": {"synchronous_commit": "on"}}} When I issue a PATCH request to http://127.0.0.1:8008/config with {"pause": null, "maximum_lag_on_syncnode": -1, "postgresql": {"parameters": {"synchronous_commit": "on"}}}
Then I receive a response code 200 Then I receive a response code 200
And I drop table on postgres0 And I drop table on postgres0
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env python
import os
import psycopg2
import sys
if __name__ == '__main__':
if not (len(sys.argv) >= 3 and sys.argv[3] == "master"):
sys.exit(1)
os.environ['PGPASSWORD'] = 'zalando'
connection = psycopg2.connect(host='127.0.0.1', port=sys.argv[1], user='postgres')
cursor = connection.cursor()
cursor.execute("SELECT slot_name FROM pg_replication_slots WHERE slot_type = 'logical'")
with open("data/postgres0/label", "w") as label:
label.write(next(iter(cursor.fetchone()), ""))
+7 -6
View File
@@ -180,6 +180,7 @@ class PatroniController(AbstractController):
config['postgresql']['data_dir'] = self._data_dir config['postgresql']['data_dir'] = self._data_dir
config['postgresql']['basebackup'] = [{'checkpoint': 'fast'}] config['postgresql']['basebackup'] = [{'checkpoint': 'fast'}]
config['postgresql']['use_unix_socket'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets config['postgresql']['use_unix_socket'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets
config['postgresql']['use_unix_socket_repl'] = os.name != 'nt' # windows doesn't yet support unix-domain sockets
config['postgresql']['pgpass'] = os.path.join(tempfile.gettempdir(), 'pgpass_' + name) config['postgresql']['pgpass'] = os.path.join(tempfile.gettempdir(), 'pgpass_' + name)
config['postgresql']['parameters'].update({ config['postgresql']['parameters'].update({
'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir, 'logging_collector': 'on', 'log_destination': 'csvlog', 'log_directory': self._output_dir,
@@ -590,10 +591,12 @@ class ExhibitorController(ZooKeeperController):
class RaftController(AbstractDcsController): class RaftController(AbstractDcsController):
CONTROLLER_ADDR = 'localhost:1234' CONTROLLER_ADDR = 'localhost:1234'
PASSWORD = '12345'
def __init__(self, context): def __init__(self, context):
super(RaftController, self).__init__(context) super(RaftController, self).__init__(context)
os.environ.update(PATRONI_RAFT_PARTNER_ADDRS="'" + self.CONTROLLER_ADDR + "'", RAFT_PORT='1234') os.environ.update(PATRONI_RAFT_PARTNER_ADDRS="'" + self.CONTROLLER_ADDR + "'",
PATRONI_RAFT_PASSWORD=self.PASSWORD, RAFT_PORT='1234')
self._raft = None self._raft = None
def _start(self): def _start(self):
@@ -614,18 +617,16 @@ class RaftController(AbstractDcsController):
def cleanup_service_tree(self): def cleanup_service_tree(self):
from patroni.dcs.raft import KVStoreTTL from patroni.dcs.raft import KVStoreTTL
from pysyncobj import SyncObjConf
if self._raft: if self._raft:
self._raft.destroy() self._raft.destroy()
self._raft._SyncObj__thread.join()
self.stop() self.stop()
os.makedirs(self._work_directory) os.makedirs(self._work_directory)
self.start() self.start()
ready_event = threading.Event() ready_event = threading.Event()
conf = SyncObjConf(appendEntriesUseBatch=False, dynamicMembershipChange=True, onReady=ready_event.set) self._raft = KVStoreTTL(ready_event.set, None, None, partner_addrs=[self.CONTROLLER_ADDR], password=self.PASSWORD)
self._raft = KVStoreTTL(None, [self.CONTROLLER_ADDR], conf) self._raft.startAutoTick()
ready_event.wait() ready_event.wait()
@@ -868,7 +869,7 @@ class WatchdogMonitor(object):
return triggered return triggered
# actions to execute on start/stop of the tests and before running invidual features # actions to execute on start/stop of the tests and before running individual features
def before_all(context): def before_all(context):
os.environ.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'}) os.environ.update({'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_PASSWORD': 'password'})
context.ci = any(a in os.environ for a in ('TRAVIS_BUILD_NUMBER', 'BUILD_NUMBER', 'GITHUB_ACTIONS')) context.ci = any(a in os.environ for a in ('TRAVIS_BUILD_NUMBER', 'BUILD_NUMBER', 'GITHUB_ACTIONS'))
+13 -3
View File
@@ -1,5 +1,5 @@
Feature: standby cluster Feature: standby cluster
Scenario: check permanent logical slots are preserved on failover/switchover Scenario: prepare the cluster with logical slots
Given I start postgres1 Given I start postgres1
Then postgres1 is a leader after 10 seconds Then postgres1 is a leader after 10 seconds
And there is a non empty initialize key in DCS after 15 seconds And there is a non empty initialize key in DCS after 15 seconds
@@ -10,15 +10,24 @@ Feature: standby cluster
When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"test_logical": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}} When I issue a PATCH request to http://127.0.0.1:8009/config with {"slots": {"test_logical": {"type": "logical", "database": "postgres", "plugin": "test_decoding"}}}
Then I receive a response code 200 Then I receive a response code 200
And I do a backup of postgres1 And I do a backup of postgres1
When I start postgres0 with callback configured When I start postgres0
Then "members/postgres0" key in DCS has state=running after 10 seconds Then "members/postgres0" key in DCS has state=running after 10 seconds
And replication works from postgres1 to postgres0 after 15 seconds And replication works from postgres1 to postgres0 after 15 seconds
@skip
Scenario: check permanent logical slots are synced to the replica
Given I run patronictl.py restart batman postgres1 --force
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
When I add the table replicate_me to postgres1
And I get all changes from logical slot test_logical on postgres1
Then Logical slot test_logical is in sync between postgres0 and postgres1 after 10 seconds
Scenario: Detach exiting node from the cluster
When I shut down postgres1 When I shut down postgres1
Then postgres0 is a leader after 10 seconds Then postgres0 is a leader after 10 seconds
And "members/postgres0" key in DCS has role=master after 3 seconds And "members/postgres0" key in DCS has role=master after 3 seconds
When I issue a GET request to http://127.0.0.1:8008/ When I issue a GET request to http://127.0.0.1:8008/
Then I receive a response code 200 Then I receive a response code 200
And there is a label with "test_logical" in postgres0 data directory
Scenario: check replication of a single table in a standby cluster Scenario: check replication of a single table in a standby cluster
Given I start postgres1 in a standby cluster batman1 as a clone of postgres0 Given I start postgres1 in a standby cluster batman1 as a clone of postgres0
@@ -35,6 +44,7 @@ Feature: standby cluster
When I start postgres2 in a cluster batman1 When I start postgres2 in a cluster batman1
Then postgres2 role is the replica after 24 seconds Then postgres2 role is the replica after 24 seconds
And table foo is present on postgres2 after 20 seconds And table foo is present on postgres2 after 20 seconds
And postgres1 does not have a logical replication slot named test_logical
Scenario: check failover Scenario: check failover
When I kill postgres1 When I kill postgres1
+28 -4
View File
@@ -1,5 +1,7 @@
import time
import psycopg2
from behave import step, then from behave import step, then
import psycopg2 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')
@@ -8,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 pg.Error as e: except psycopg2.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)
@@ -22,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 pg.Error: except psycopg2.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)
@@ -32,5 +34,27 @@ 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 pg.Error: except psycopg2.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)
@step('Logical slot {slot_name:w} is in sync between {pg_name1:w} and {pg_name2:w} after {time_limit:d} seconds')
def logical_slots_in_sync(context, slot_name, pg_name1, pg_name2, time_limit):
time_limit *= context.timeout_multiplier
max_time = time.time() + int(time_limit)
while time.time() < max_time:
try:
query = "SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = '{0}'".format(slot_name)
slot1 = context.pctl.query(pg_name1, query).fetchone()
slot2 = context.pctl.query(pg_name2, query).fetchone()
if slot1[0] == slot2[0]:
return
except Exception:
pass
time.sleep(1)
assert False, "Logical slot {0} is not in sync between {1} and {2}".format(slot_name, pg_name1, pg_name2)
@step('I get all changes from logical slot {slot_name:w} on {pg_name:w}')
def logical_slot_get_changes(context, slot_name, pg_name):
context.pctl.query(pg_name, "SELECT * FROM pg_logical_slot_get_changes('{0}', NULL, NULL)".format(slot_name))
+2 -12
View File
@@ -14,17 +14,6 @@ executable = sys.executable if os.name != 'nt' else sys.executable.replace('\\',
callback = executable + " features/callback2.py " callback = executable + " features/callback2.py "
@step('I start {name:w} with callback configured')
def start_patroni_with_callbacks(context, name):
return context.pctl.start(name, custom_config={
"postgresql": {
"callbacks": {
"on_role_change": executable + " features/callback.py"
}
}
})
@step('I start {name:w} in a cluster {cluster_name:w}') @step('I start {name:w} in a cluster {cluster_name:w}')
def start_patroni(context, name, cluster_name): def start_patroni(context, name, cluster_name):
return context.pctl.start(name, custom_config={ return context.pctl.start(name, custom_config={
@@ -55,7 +44,8 @@ def start_patroni_standby_cluster(context, name, cluster_name, name2):
"port": port, "port": port,
"primary_slot_name": "pm_1", "primary_slot_name": "pm_1",
"create_replica_methods": ["backup_restore", "basebackup"] "create_replica_methods": ["backup_restore", "basebackup"]
} },
"postgresql": {"parameters": {"wal_level": "logical"}}
} }
}, },
"postgresql": { "postgresql": {
+1
View File
@@ -74,6 +74,7 @@ class Patroni(AbstractPatroniDaemon):
if local: if local:
self.tags = self.get_tags() self.tags = self.get_tags()
self.request.reload_config(self.config) self.request.reload_config(self.config)
if local or self.api.reload_local_certificate():
self.api.reload_config(self.config['restapi']) self.api.reload_config(self.config['restapi'])
self.watchdog.reload_config(self.config) self.watchdog.reload_config(self.config)
self.postgresql.reload_config(self.config['postgresql'], sighup) self.postgresql.reload_config(self.config['postgresql'], sighup)
+194 -21
View File
@@ -12,6 +12,7 @@ import six
import socket import socket
import sys import sys
from ipaddress import ip_address, ip_network as _ip_network
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from six.moves.socketserver import ThreadingMixIn from six.moves.socketserver import ThreadingMixIn
from six.moves.urllib_parse import urlparse, parse_qs from six.moves.urllib_parse import urlparse, parse_qs
@@ -25,6 +26,10 @@ from .utils import deep_compare, enable_keepalive, parse_bool, patch_config, Ret
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def ip_network(value):
return _ip_network(value.decode('utf-8') if six.PY2 else value, False)
class RestApiHandler(BaseHTTPRequestHandler): class RestApiHandler(BaseHTTPRequestHandler):
def _write_status_code_only(self, status_code): def _write_status_code_only(self, status_code):
@@ -45,19 +50,19 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.wfile.write(body.encode('utf-8')) self.wfile.write(body.encode('utf-8'))
def _write_json_response(self, status_code, response): def _write_json_response(self, status_code, response):
self._write_response(status_code, json.dumps(response), content_type='application/json') self._write_response(status_code, json.dumps(response, default=str), content_type='application/json')
def check_auth(func): def check_access(func):
"""Decorator function to check authorization header or client certificates """Decorator function to check the source ip, authorization header. or client certificates
Usage example: Usage example:
@check_auth @check_access
def do_PUT_foo(): def do_PUT_foo():
pass pass
""" """
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
if self.server.check_auth(self): if self.server.check_access(self):
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
return wrapper return wrapper
@@ -97,7 +102,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
patroni = self.server.patroni patroni = self.server.patroni
cluster = patroni.dcs.cluster cluster = patroni.dcs.cluster
leader_optime = cluster and cluster.last_leader_operation or 0 leader_optime = cluster and cluster.last_lsn or 0
replayed_location = response.get('xlog', {}).get('replayed_location', 0) replayed_location = response.get('xlog', {}).get('replayed_location', 0)
max_replica_lag = parse_int(self.path_query.get('lag', [sys.maxsize])[0], 'B') max_replica_lag = parse_int(self.path_query.get('lag', [sys.maxsize])[0], 'B')
if max_replica_lag is None: if max_replica_lag is None:
@@ -108,9 +113,11 @@ class RestApiHandler(BaseHTTPRequestHandler):
response.get('role') == 'replica' and response.get('state') == 'running' else 503 response.get('role') == 'replica' and response.get('state') == 'running' else 503
if not cluster and patroni.ha.is_paused(): if not cluster and patroni.ha.is_paused():
leader_status_code = 200 if response.get('role') in ('master', 'standby_leader') else 503
primary_status_code = 200 if response.get('role') == 'master' else 503 primary_status_code = 200 if response.get('role') == 'master' else 503
standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503 standby_leader_status_code = 200 if response.get('role') == 'standby_leader' else 503
elif patroni.ha.is_leader(): elif patroni.ha.is_leader():
leader_status_code = 200
if patroni.ha.is_standby_cluster(): if patroni.ha.is_standby_cluster():
primary_status_code = replica_status_code = 503 primary_status_code = replica_status_code = 503
standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503 standby_leader_status_code = 200 if response.get('role') in ('replica', 'standby_leader') else 503
@@ -118,14 +125,20 @@ class RestApiHandler(BaseHTTPRequestHandler):
primary_status_code = 200 primary_status_code = 200
standby_leader_status_code = 503 standby_leader_status_code = 503
else: else:
primary_status_code = standby_leader_status_code = 503 leader_status_code = primary_status_code = standby_leader_status_code = 503
status_code = 503 status_code = 503
ignore_tags = False
if 'standby_leader' in path or 'standby-leader' in path: if 'standby_leader' in path or 'standby-leader' in path:
status_code = standby_leader_status_code status_code = standby_leader_status_code
elif 'master' in path or 'leader' in path or 'primary' in path or 'read-write' in path: ignore_tags = True
elif 'leader' in path:
status_code = leader_status_code
ignore_tags = True
elif 'master' in path or 'primary' in path or 'read-write' in path:
status_code = primary_status_code status_code = primary_status_code
ignore_tags = True
elif 'replica' in path: elif 'replica' in path:
status_code = replica_status_code status_code = replica_status_code
elif 'read-only' in path: elif 'read-only' in path:
@@ -140,6 +153,25 @@ class RestApiHandler(BaseHTTPRequestHandler):
elif path in ('/async', '/asynchronous') and not is_synchronous: elif path in ('/async', '/asynchronous') and not is_synchronous:
status_code = replica_status_code status_code = replica_status_code
# check for user defined tags in query params
if not ignore_tags and status_code == 200:
qs_tag_prefix = "tag_"
for qs_key, qs_value in self.path_query.items():
if not qs_key.startswith(qs_tag_prefix):
continue
qs_key = qs_key[len(qs_tag_prefix):]
qs_value = qs_value[0]
instance_tag_value = patroni.tags.get(qs_key)
# tag not registered for instance
if instance_tag_value is None:
status_code = 503
break
if not isinstance(instance_tag_value, six.string_types):
instance_tag_value = str(instance_tag_value).lower()
if instance_tag_value != qs_value:
status_code = 503
break
if write_status_code_only: # when haproxy sends OPTIONS request it reads only status code and nothing more if write_status_code_only: # when haproxy sends OPTIONS request it reads only status code and nothing more
self._write_status_code_only(status_code) self._write_status_code_only(status_code)
else: else:
@@ -180,6 +212,84 @@ class RestApiHandler(BaseHTTPRequestHandler):
else: else:
self.send_error(502) self.send_error(502)
def do_GET_metrics(self):
postgres = self.get_postgresql_status(True)
patroni = self.server.patroni
epoch = datetime.datetime(1970, 1, 1, tzinfo=tzutc)
metrics = []
scope_label = '{{scope="{0}"}}'.format(patroni.postgresql.scope)
metrics.append("# HELP patroni_version Patroni semver without periods.")
metrics.append("# TYPE patroni_version gauge")
padded_semver = ''.join([x.zfill(2) for x in patroni.version.split('.')]) # 2.0.2 => 020002
metrics.append("patroni_version{0} {1}".format(scope_label, padded_semver))
metrics.append("# HELP patroni_postgres_running Value is 1 if Postgres is running, 0 otherwise.")
metrics.append("# TYPE patroni_postgres_running gauge")
metrics.append("patroni_postgres_running{0} {1}".format(scope_label, int(postgres['state'] == 'running')))
metrics.append("# HELP patroni_postmaster_start_time Epoch seconds since Postgres started.")
metrics.append("# TYPE patroni_postmaster_start_time gauge")
postmaster_start_time = postgres.get('postmaster_start_time')
postmaster_start_time = (postmaster_start_time - epoch).total_seconds() if postmaster_start_time else 0
metrics.append("patroni_postmaster_start_time{0} {1}".format(scope_label, postmaster_start_time))
metrics.append("# HELP patroni_master Value is 1 if this node is the leader, 0 otherwise.")
metrics.append("# TYPE patroni_master gauge")
metrics.append("patroni_master{0} {1}".format(scope_label, int(postgres['role'] == 'master')))
metrics.append("# HELP patroni_xlog_location Current location of the Postgres"
" transaction log, 0 if this node is not the leader.")
metrics.append("# TYPE patroni_xlog_location counter")
metrics.append("patroni_xlog_location{0} {1}".format(scope_label, postgres.get('xlog', {}).get('location', 0)))
metrics.append("# HELP patroni_standby_leader Value is 1 if this node is the standby_leader, 0 otherwise.")
metrics.append("# TYPE patroni_standby_leader gauge")
metrics.append("patroni_standby_leader{0} {1}".format(scope_label, int(postgres['role'] == 'standby_leader')))
metrics.append("# HELP patroni_replica Value is 1 if this node is a replica, 0 otherwise.")
metrics.append("# TYPE patroni_replica gauge")
metrics.append("patroni_replica{0} {1}".format(scope_label, int(postgres['role'] == 'replica')))
metrics.append("# HELP patroni_xlog_received_location Current location of the received"
" Postgres transaction log, 0 if this node is not a replica.")
metrics.append("# TYPE patroni_xlog_received_location counter")
metrics.append("patroni_xlog_received_location{0} {1}"
.format(scope_label, postgres.get('xlog', {}).get('received_location', 0)))
metrics.append("# HELP patroni_xlog_replayed_location Current location of the replayed"
" Postgres transaction log, 0 if this node is not a replica.")
metrics.append("# TYPE patroni_xlog_replayed_location counter")
metrics.append("patroni_xlog_replayed_location{0} {1}"
.format(scope_label, postgres.get('xlog', {}).get('replayed_location', 0)))
metrics.append("# HELP patroni_xlog_replayed_timestamp Current timestamp of the replayed"
" Postgres transaction log, 0 if null.")
metrics.append("# TYPE patroni_xlog_replayed_timestamp gauge")
replayed_timestamp = postgres.get('xlog', {}).get('replayed_timestamp')
replayed_timestamp = (replayed_timestamp - epoch).total_seconds() if replayed_timestamp else 0
metrics.append("patroni_xlog_replayed_timestamp{0} {1}".format(scope_label, replayed_timestamp))
metrics.append("# HELP patroni_xlog_paused Value is 1 if the Postgres xlog is paused, 0 otherwise.")
metrics.append("# TYPE patroni_xlog_paused gauge")
metrics.append("patroni_xlog_paused{0} {1}"
.format(scope_label, int(postgres.get('xlog', {}).get('paused', False) is True)))
metrics.append("# HELP patroni_postgres_server_version Version of Postgres (if running), 0 otherwise.")
metrics.append("# TYPE patroni_postgres_server_version gauge")
metrics.append("patroni_postgres_server_version {0} {1}".format(scope_label, postgres.get('server_version', 0)))
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("patroni_cluster_unlocked{0} {1}".format(scope_label, int(postgres['cluster_unlocked'])))
metrics.append("# HELP patroni_postgres_timeline Postgres timeline of this node (if running), 0 otherwise.")
metrics.append("# TYPE patroni_postgres_timeline counter")
metrics.append("patroni_postgres_timeline{0} {1}".format(scope_label, postgres.get('timeline', 0)))
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):
if 'content-length' not in self.headers: if 'content-length' not in self.headers:
return self.send_error(411) if not body_is_optional else {} return self.send_error(411) if not body_is_optional else {}
@@ -194,7 +304,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
logger.exception('Bad request') logger.exception('Bad request')
self.send_error(400) self.send_error(400)
@check_auth @check_access
def do_PATCH_config(self): def do_PATCH_config(self):
request = self._read_json_content() request = self._read_json_content()
if request: if request:
@@ -209,7 +319,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
self.server.patroni.ha.wakeup() self.server.patroni.ha.wakeup()
self._write_json_response(200, data) self._write_json_response(200, data)
@check_auth @check_access
def do_PUT_config(self): def do_PUT_config(self):
request = self._read_json_content() request = self._read_json_content()
if request: if request:
@@ -220,7 +330,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
return self.send_error(502) return self.send_error(502)
self._write_json_response(200, request) self._write_json_response(200, request)
@check_auth @check_access
def do_POST_reload(self): def do_POST_reload(self):
self.server.patroni.sighup_handler() self.server.patroni.sighup_handler()
self._write_response(202, 'reload scheduled') self._write_response(202, 'reload scheduled')
@@ -246,7 +356,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
status_code = 422 status_code = 422
return (status_code, error, scheduled_at) return (status_code, error, scheduled_at)
@check_auth @check_access
def do_POST_restart(self): def do_POST_restart(self):
status_code = 500 status_code = 500
data = 'restart failed' data = 'restart failed'
@@ -307,7 +417,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
status_code = 409 status_code = 409
self._write_response(status_code, data) self._write_response(status_code, data)
@check_auth @check_access
def do_DELETE_restart(self): def do_DELETE_restart(self):
if self.server.patroni.ha.delete_future_restart(): if self.server.patroni.ha.delete_future_restart():
data = "scheduled restart deleted" data = "scheduled restart deleted"
@@ -317,7 +427,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
code = 404 code = 404
self._write_response(code, data) self._write_response(code, data)
@check_auth @check_access
def do_DELETE_switchover(self): def do_DELETE_switchover(self):
failover = self.server.patroni.dcs.get_cluster().failover failover = self.server.patroni.dcs.get_cluster().failover
if failover and failover.scheduled_at: if failover and failover.scheduled_at:
@@ -331,7 +441,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
code = 404 code = 404
self._write_response(code, data) self._write_response(code, data)
@check_auth @check_access
def do_POST_reinitialize(self): def do_POST_reinitialize(self):
request = self._read_json_content(body_is_optional=True) request = self._read_json_content(body_is_optional=True)
@@ -363,7 +473,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
if not cluster.failover: if not cluster.failover:
return 503, action.title() + ' failed' return 503, action.title() + ' failed'
except Exception as e: except Exception as e:
logger.debug('Exception occured during polling %s result: %s', action, e) logger.debug('Exception occurred during polling %s result: %s', action, e)
return 503, action.title() + ' status unknown' return 503, action.title() + ' status unknown'
def is_failover_possible(self, cluster, leader, candidate, action): def is_failover_possible(self, cluster, leader, candidate, action):
@@ -388,7 +498,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
return None return None
return action + ' is not possible: no good candidates have been found' return action + ' is not possible: no good candidates have been found'
@check_auth @check_access
def do_POST_failover(self, action='failover'): def do_POST_failover(self, action='failover'):
request = self._read_json_content() request = self._read_json_content()
(status_code, data) = (400, '') (status_code, data) = (400, '')
@@ -476,7 +586,7 @@ class RestApiHandler(BaseHTTPRequestHandler):
if postgresql.state not in ('running', 'restarting', 'starting'): if postgresql.state not in ('running', 'restarting', 'starting'):
raise RetryFailedError('') raise RetryFailedError('')
stmt = ("SELECT " + postgresql.POSTMASTER_START_TIME + ", " + postgresql.TL_LSN + "," stmt = ("SELECT " + postgresql.POSTMASTER_START_TIME + ", " + postgresql.TL_LSN + ","
" pg_catalog.to_char(pg_catalog.pg_last_xact_replay_timestamp(), 'YYYY-MM-DD HH24:MI:SS.MS TZ')," " pg_catalog.pg_last_xact_replay_timestamp(),"
" pg_catalog.array_to_json(pg_catalog.array_agg(pg_catalog.row_to_json(ri))) " " pg_catalog.array_to_json(pg_catalog.array_agg(pg_catalog.row_to_json(ri))) "
"FROM (SELECT (SELECT rolname FROM pg_authid WHERE oid = usesysid) AS usename," "FROM (SELECT (SELECT rolname FROM pg_authid WHERE oid = usesysid) AS usename,"
" application_name, client_addr, w.state, sync_state, sync_priority" " application_name, client_addr, w.state, sync_state, sync_priority"
@@ -536,9 +646,10 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
self.patroni = patroni self.patroni = patroni
self.__listen = None self.__listen = None
self.__ssl_options = None self.__ssl_options = None
self.http_extra_headers = {}
self.reload_config(config) self.reload_config(config)
self.daemon = True self.daemon = True
self.__ssl_serial_number = None
self._received_new_cert = False
def query(self, sql, *params): def query(self, sql, *params):
cursor = None cursor = None
@@ -568,7 +679,35 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
if not auth_header.startswith('Basic ') or not self.check_basic_auth_key(auth_header[6:]): if not auth_header.startswith('Basic ') or not self.check_basic_auth_key(auth_header[6:]):
return 'not authenticated' return 'not authenticated'
def check_auth(self, rh): @staticmethod
def __resolve_ips(host, port):
try:
for _, _, _, _, sa in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP):
yield ip_network(sa[0])
except Exception as e:
logger.error('Failed to resolve %s: %r', host, e)
def __members_ips(self):
cluster = self.patroni.dcs.cluster
if self.__allowlist_include_members and cluster:
for member in cluster.members:
if member.api_url:
try:
r = urlparse(member.api_url)
host = r.hostname
port = r.port or (443 if r.scheme == 'https' else 80)
for ip in self.__resolve_ips(host, port):
yield ip
except Exception as e:
logger.debug('Failed to parse url %s: %r', member.api_url, e)
def check_access(self, rh):
if self.__allowlist or self.__allowlist_include_members:
incoming_ip = rh.client_address[0]
incoming_ip = ip_address(incoming_ip.decode('utf-8') if six.PY2 else incoming_ip)
if not any(incoming_ip in net for net in self.__allowlist + tuple(self.__members_ips())):
return rh._write_response(403, 'Access is denied')
if not hasattr(rh.request, 'getpeercert') or not rh.request.getpeercert(): # valid client cert isn't present if not hasattr(rh.request, 'getpeercert') or not rh.request.getpeercert(): # valid client cert isn't present
if self.__protocol == 'https' and self.__ssl_options.get('verify_client') in ('required', 'optional'): if self.__protocol == 'https' and self.__ssl_options.get('verify_client') in ('required', 'optional'):
return rh._write_response(403, 'client certificate required') return rh._write_response(403, 'client certificate required')
@@ -624,6 +763,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
self.__listen = listen self.__listen = listen
self.__ssl_options = ssl_options self.__ssl_options = ssl_options
self._received_new_cert = False # reset to False after reload_config()
self.__httpserver_init(host, port) self.__httpserver_init(host, port)
Thread.__init__(self, target=self.serve_forever) Thread.__init__(self, target=self.serve_forever)
@@ -646,6 +786,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
ctx.verify_mode = modes[verify_client] ctx.verify_mode = modes[verify_client]
else: else:
logger.error('Bad value in the "restapi.verify_client": %s', verify_client) logger.error('Bad value in the "restapi.verify_client": %s', verify_client)
self.__ssl_serial_number = self.get_certificate_serial_number()
self.socket = ctx.wrap_socket(self.socket, server_side=True) self.socket = ctx.wrap_socket(self.socket, server_side=True)
if reloading_config: if reloading_config:
self.start() self.start()
@@ -673,10 +814,42 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
_, request = request # SSLSocket _, request = request # SSLSocket
return super(RestApiServer, self).shutdown_request(request) return super(RestApiServer, self).shutdown_request(request)
def get_certificate_serial_number(self):
if self.__ssl_options.get('certfile'):
import ssl
try:
crt = ssl._ssl._test_decode_cert(self.__ssl_options['certfile'])
return crt.get('serialNumber')
except ssl.SSLError as e:
logger.error('Failed to get serial number from certificate %s: %r', self.__ssl_options['certfile'], e)
def reload_local_certificate(self):
if self.__protocol == 'https':
on_disk_cert_serial_number = self.get_certificate_serial_number()
if on_disk_cert_serial_number != self.__ssl_serial_number:
self._received_new_cert = True
self.__ssl_serial_number = on_disk_cert_serial_number
return True
def _build_allowlist(self, value):
if isinstance(value, list):
for v in value:
if '/' in v: # netmask
try:
yield ip_network(v)
except Exception as e:
logger.error('Invalid value "%s" in the allowlist: %r', v, e)
else: # ip or hostname, try to resolve it
for ip in self.__resolve_ips(v, 8080):
yield ip
def reload_config(self, config): def reload_config(self, config):
if 'listen' not in config: # changing config in runtime if 'listen' not in config: # changing config in runtime
raise ValueError('Can not find "restapi.listen" config') raise ValueError('Can not find "restapi.listen" config')
self.__allowlist = tuple(self._build_allowlist(config.get('allowlist')))
self.__allowlist_include_members = config.get('allowlist_include_members')
ssl_options = {n: config[n] for n in ('certfile', 'keyfile', 'keyfile_password', ssl_options = {n: config[n] for n in ('certfile', 'keyfile', 'keyfile_password',
'cafile', 'ciphers') if n in config} 'cafile', 'ciphers') if n in config}
@@ -686,7 +859,7 @@ class RestApiServer(ThreadingMixIn, HTTPServer, Thread):
if isinstance(config.get('verify_client'), six.string_types): if isinstance(config.get('verify_client'), six.string_types):
ssl_options['verify_client'] = config['verify_client'].lower() ssl_options['verify_client'] = config['verify_client'].lower()
if self.__listen != config['listen'] or self.__ssl_options != ssl_options: if self.__listen != config['listen'] or self.__ssl_options != ssl_options or self._received_new_cert:
self.__initialize(config['listen'], ssl_options) self.__initialize(config['listen'], ssl_options)
self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')) if 'auth' in config else None self.__auth_key = base64.b64encode(config['auth'].encode('utf-8')) if 'auth' in config else None
+41 -21
View File
@@ -232,7 +232,7 @@ class Config(object):
for name, value in (value or {}).items(): for name, value in (value or {}).items():
if name in self.__DEFAULT_CONFIG['standby_cluster']: if name in self.__DEFAULT_CONFIG['standby_cluster']:
config['standby_cluster'][name] = deepcopy(value) config['standby_cluster'][name] = deepcopy(value)
elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overriden from DCS elif name in config: # only variables present in __DEFAULT_CONFIG allowed to be overridden from DCS
if name in ('synchronous_mode', 'synchronous_mode_strict'): if name in ('synchronous_mode', 'synchronous_mode_strict'):
config[name] = value config[name] = value
else: else:
@@ -268,11 +268,42 @@ 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']) 'https_extra_headers', 'allowlist', 'allowlist_include_members'])
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile']) _set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile'])
_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'])
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
for first, second in (('restapi', 'allowlist_include_members'), ('ctl', 'insecure')):
value = ret.get(first, {}).pop(second, None)
if value:
value = parse_bool(value)
if value is not None:
ret[first][second] = value
for second in ('max_queue_size', 'file_size', 'file_num'):
value = ret.get('log', {}).pop(second, None)
if value:
value = parse_int(value)
if value is not None:
ret['log'][second] = value
def _parse_list(value):
if not (value.strip().startswith('-') or '[' in value):
value = '[{0}]'.format(value)
try:
return yaml.safe_load(value)
except Exception:
logger.exception('Exception when parsing list %s', value)
return None
for first, second in (('raft', 'partner_addrs'), ('restapi', 'allowlist')):
value = ret.get(first, {}).pop(second, None)
if value:
value = _parse_list(value)
if value:
ret[first][second] = value
def _parse_dict(value): def _parse_dict(value):
if not value.strip().startswith('{'): if not value.strip().startswith('{'):
@@ -283,11 +314,13 @@ class Config(object):
logger.exception('Exception when parsing dict %s', value) logger.exception('Exception when parsing dict %s', value)
return None return None
value = ret.get('log', {}).pop('loggers', None) for first, params in (('restapi', ('http_extra_headers', 'https_extra_headers')), ('log', ('loggers',))):
if value: for second in params:
value = _parse_dict(value) value = ret.get(first, {}).pop(second, None)
if value: if value:
ret['log']['loggers'] = value value = _parse_dict(value)
if value:
ret[first][second] = value
def _get_auth(name, params=None): def _get_auth(name, params=None):
ret = {} ret = {}
@@ -310,19 +343,6 @@ class Config(object):
if authentication: if authentication:
ret['postgresql']['authentication'] = authentication ret['postgresql']['authentication'] = authentication
def _parse_list(value):
if not (value.strip().startswith('-') or '[' in value):
value = '[{0}]'.format(value)
try:
return yaml.safe_load(value)
except Exception:
logger.exception('Exception when parsing list %s', value)
return None
_set_section_values('raft', ['data_dir', 'self_addr', 'partner_addrs', 'password', 'bind_addr'])
if 'raft' in ret and 'partner_addrs' in ret['raft']:
ret['raft']['partner_addrs'] = _parse_list(ret['raft']['partner_addrs'])
for param in list(os.environ.keys()): for param in list(os.environ.keys()):
if param.startswith(PATRONI_ENV_PREFIX): if param.startswith(PATRONI_ENV_PREFIX):
# PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..) # PATRONI_(ETCD|CONSUL|ZOOKEEPER|EXHIBITOR|...)_(HOSTS?|PORT|..)
@@ -339,7 +359,7 @@ class Config(object):
value = value and _parse_list(value) value = value and _parse_list(value)
elif suffix == 'LABELS': elif suffix == 'LABELS':
value = _parse_dict(value) value = _parse_dict(value)
elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE'): elif suffix in ('USE_PROXIES', 'REGISTER_SERVICE', 'USE_ENDPOINTS', 'BYPASS_API_SERVICE', 'VERIFY'):
value = parse_bool(value) value = parse_bool(value)
if value: if value:
ret[name.lower()][suffix.lower()] = value ret[name.lower()][suffix.lower()] = value
+138 -40
View File
@@ -13,12 +13,13 @@ import time
from collections import defaultdict, namedtuple from collections import defaultdict, namedtuple
from copy import deepcopy from copy import deepcopy
from patroni.exceptions import PatroniFatalException
from patroni.utils import parse_bool, uri
from random import randint from random import randint
from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl from six.moves.urllib_parse import urlparse, urlunparse, parse_qsl
from threading import Event, Lock from threading import Event, Lock
from ..exceptions import PatroniFatalException
from ..utils import deep_compare, parse_bool, uri
slot_name_re = re.compile('^[a-z0-9_]{1,63}$') slot_name_re = re.compile('^[a-z0-9_]{1,63}$')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -67,7 +68,11 @@ def dcs_modules():
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
toc = set() toc = set()
for importer in pkgutil.iter_importers(dcs_dirname): # dcs_dirname may contain a dot, which causes pkgutil.iter_importers()
# to misinterpret the path as a package name. This can be avoided
# altogether by not passing a path at all, because PyInstaller's
# FrozenImporter is a singleton and registered as top-level finder.
for importer in pkgutil.iter_importers():
if hasattr(importer, 'toc'): if hasattr(importer, 'toc'):
toc |= importer.toc toc |= importer.toc
return [module for module in toc if module.startswith(module_prefix) and module.count('.') == 2] return [module for module in toc if module.startswith(module_prefix) and module.count('.') == 2]
@@ -133,6 +138,8 @@ class Member(namedtuple('Member', 'index,name,session,data')):
else: else:
try: try:
data = json.loads(data) data = json.loads(data)
if not isinstance(data, dict):
data = {}
except (TypeError, ValueError): except (TypeError, ValueError):
data = {} data = {}
return Member(index, name, session, data) return Member(index, name, session, data)
@@ -206,6 +213,15 @@ class Member(namedtuple('Member', 'index,name,session,data')):
def is_running(self): def is_running(self):
return self.state == 'running' return self.state == 'running'
@property
def version(self):
version = self.data.get('version')
if version:
try:
return tuple(map(int, version.split('.')))
except Exception:
logger.debug('Failed to parse Patroni version %s', version)
class RemoteMember(Member): class RemoteMember(Member):
""" Represents a remote master for a standby cluster """ Represents a remote master for a standby cluster
@@ -259,14 +275,10 @@ class Leader(namedtuple('Leader', 'index,session,member')):
""" """
>>> Leader(1, '', Member.from_node(1, '', '', '{"version":"z"}')).checkpoint_after_promote >>> Leader(1, '', Member.from_node(1, '', '', '{"version":"z"}')).checkpoint_after_promote
""" """
version = self.data.get('version') version = self.member.version
if version: # 1.5.6 is the last version which doesn't expose checkpoint_after_promote: false
try: if version and version > (1, 5, 6):
# 1.5.6 is the last version which doesn't expose checkpoint_after_promote: false return self.data.get('role') == 'master' and 'checkpoint_after_promote' not in self.data
if tuple(map(int, version.split('.'))) > (1, 5, 6):
return self.data['role'] == 'master' and 'checkpoint_after_promote' not in self.data
except Exception:
logger.debug('Failed to parse Patroni version %s', version)
class Failover(namedtuple('Failover', 'index,leader,candidate,scheduled_at')): class Failover(namedtuple('Failover', 'index,leader,candidate,scheduled_at')):
@@ -432,19 +444,20 @@ class TimelineHistory(namedtuple('TimelineHistory', 'index,value,lines')):
return TimelineHistory(index, value, lines) return TimelineHistory(index, value, lines)
class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operation,members,failover,sync,history')): class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_lsn,members,failover,sync,history,slots')):
"""Immutable object (namedtuple) which represents PostgreSQL cluster. """Immutable object (namedtuple) which represents PostgreSQL cluster.
Consists of the following fields: Consists of the following fields:
:param initialize: shows whether this cluster has initialization key stored in DC or not. :param initialize: shows whether this cluster has initialization key stored in DC or not.
:param config: global dynamic configuration, reference to `ClusterConfig` object :param config: global dynamic configuration, reference to `ClusterConfig` object
:param leader: `Leader` object which represents current leader of the cluster :param leader: `Leader` object which represents current leader of the cluster
:param last_leader_operation: int or long object containing position of last known leader operation. :param last_lsn: int or long object containing position of last known leader LSN.
This value is stored in `/optime/leader` key This value is stored in the `/status` key or `/optime/leader` (legacy) key
:param members: list of Member object, all PostgreSQL cluster members including leader :param members: list of Member object, all PostgreSQL cluster members including leader
:param failover: reference to `Failover` object :param failover: reference to `Failover` object
:param sync: reference to `SyncState` object, last observed synchronous replication state. :param sync: reference to `SyncState` object, last observed synchronous replication state.
:param history: reference to `TimelineHistory` object :param history: reference to `TimelineHistory` object
:param slots: state of permanent logical replication slots on the primary in the format: {"slot_name": int}
""" """
def is_unlocked(self): def is_unlocked(self):
@@ -470,22 +483,41 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
def is_synchronous_mode(self): def is_synchronous_mode(self):
return self.check_mode('synchronous_mode') return self.check_mode('synchronous_mode')
def get_replication_slots(self, my_name, role): @property
def __permanent_slots(self):
return self.config and self.config.permanent_slots or {}
@property
def __permanent_physical_slots(self):
return {name: value for name, value in self.__permanent_slots.items()
if not value or isinstance(value, dict) and value.get('type', 'physical') == 'physical'}
@property
def __permanent_logical_slots(self):
return {name: value for name, value in self.__permanent_slots.items() if isinstance(value, dict)
and value.get('type', 'logical') == 'logical' and value.get('database') and value.get('plugin')}
@property
def use_slots(self):
return self.config and self.config.data.get('postgresql', {}).get('use_slots', True)
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
# the current master, because that member would replicate from elsewhere. We still create the slot if # the current master, because that member would replicate from elsewhere. We still create the slot if
# the replicatefrom destination member is currently not a member of the cluster (fallback to the # the replicatefrom destination member is currently not a member of the cluster (fallback to the
# master), or if replicatefrom destination member happens to be the current master # master), or if replicatefrom destination member happens to be the current master
use_slots = self.config and self.config.data.get('postgresql', {}).get('use_slots', True) use_slots = self.use_slots
if role in ('master', 'standby_leader'): if role in ('master', 'standby_leader'):
slot_members = [m.name for m in self.members if use_slots and m.name != my_name and slot_members = [m.name for m in self.members if use_slots and m.name != my_name and
(m.replicatefrom is None or m.replicatefrom == my_name or (m.replicatefrom is None or m.replicatefrom == my_name or
not self.has_member(m.replicatefrom))] not self.has_member(m.replicatefrom))]
permanent_slots = (self.config and self.config.permanent_slots or {}).copy() permanent_slots = self.__permanent_slots if use_slots and \
role == 'master' else self.__permanent_physical_slots
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 = {} 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}
@@ -499,6 +531,7 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
for k, v in slot_conflicts.items() if len(v) > 1)) for k, v in slot_conflicts.items() if len(v) > 1))
# "merge" replication slots for members with permanent_replication_slots # "merge" replication slots for members with permanent_replication_slots
disabled_permanent_logical_slots = []
for name, value in permanent_slots.items(): for name, value in permanent_slots.items():
if not slot_name_re.match(name): if not slot_name_re.match(name):
logger.error("Invalid permanent replication slot name '%s'", name) logger.error("Invalid permanent replication slot name '%s'", name)
@@ -516,7 +549,9 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
slots[name] = value slots[name] = value
continue continue
elif value['type'] == 'logical' and value.get('database') and value.get('plugin'): elif value['type'] == 'logical' and value.get('database') and value.get('plugin'):
if name in slots: if major_version < 110000:
disabled_permanent_logical_slots.append(name)
elif name in slots:
logger.error("Permanent logical replication slot {'%s': %s} is conflicting with" + logger.error("Permanent logical replication slot {'%s': %s} is conflicting with" +
" physical replication slot for cluster member", name, value) " physical replication slot for cluster member", name, value)
else: else:
@@ -525,20 +560,53 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name]) logger.error("Bad value for slot '%s' in permanent_slots: %s", name, permanent_slots[name])
if disabled_permanent_logical_slots and show_error:
logger.error("Permanent logical replication slots supported by Patroni only starting from PostgreSQL 11. "
"Following slots will not be created: %s.", disabled_permanent_logical_slots)
return slots return slots
def has_permanent_logical_slots(self, name): def has_permanent_logical_slots(self, my_name, nofailover, major_version=110000):
slots = self.get_replication_slots(name, 'master').values() if major_version < 110000:
return False
slots = self.get_replication_slots(my_name, 'replica', nofailover, major_version).values()
return any(v for v in slots if v.get("type") == "logical") return any(v for v in slots if v.get("type") == "logical")
def should_enforce_hot_standby_feedback(self, my_name, nofailover, major_version):
"""
The hot_standby_feedback must be enabled if the current replica has logical slots
or it is working as a cascading replica for the other node that has logical slots.
"""
if major_version < 110000:
return False
if self.has_permanent_logical_slots(my_name, nofailover, major_version):
return True
if self.use_slots:
members = [m for m in self.members if m.replicatefrom == my_name and m.name != self.leader.name]
return any(self.should_enforce_hot_standby_feedback(m.name, m.nofailover, major_version) for m in members)
return False
def get_my_slot_name_on_primary(self, my_name, replicatefrom):
"""
P <-- I <-- L
In case of cascading replication we have to check not our physical slot,
but slot of the replica that connects us to the primary.
"""
m = self.get_member(replicatefrom, False) if replicatefrom else None
return self.get_my_slot_name_on_primary(m.name, m.replicatefrom) if m else slot_name_from_member_name(my_name)
@property @property
def timeline(self): def timeline(self):
""" """
>>> Cluster(0, 0, 0, 0, 0, 0, 0, 0).timeline >>> Cluster(0, 0, 0, 0, 0, 0, 0, 0, 0).timeline
0 0
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]')).timeline >>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[]'), 0).timeline
1 1
>>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]')).timeline >>> Cluster(0, 0, 0, 0, 0, 0, 0, TimelineHistory.from_node(1, '[["a"]]'), 0).timeline
0 0
""" """
if self.history: if self.history:
@@ -551,6 +619,10 @@ class Cluster(namedtuple('Cluster', 'initialize,config,leader,last_leader_operat
return 1 return 1
return 0 return 0
@property
def min_version(self):
return next(iter(sorted(filter(lambda v: v, [m.version for m in self.members])) + [None]))
@six.add_metaclass(abc.ABCMeta) @six.add_metaclass(abc.ABCMeta)
class AbstractDCS(object): class AbstractDCS(object):
@@ -562,7 +634,8 @@ class AbstractDCS(object):
_HISTORY = 'history' _HISTORY = 'history'
_MEMBERS = 'members/' _MEMBERS = 'members/'
_OPTIME = 'optime' _OPTIME = 'optime'
_LEADER_OPTIME = _OPTIME + '/' + _LEADER _STATUS = 'status' # JSON, contains "leader_lsn" and confirmed_flush_lsn of logical "slots" on the leader
_LEADER_OPTIME = _OPTIME + '/' + _LEADER # legacy
_SYNC = 'sync' _SYNC = 'sync'
def __init__(self, config): def __init__(self, config):
@@ -578,7 +651,8 @@ class AbstractDCS(object):
self._cluster = None self._cluster = None
self._cluster_valid_till = 0 self._cluster_valid_till = 0
self._cluster_thread_lock = Lock() self._cluster_thread_lock = Lock()
self._last_leader_operation = '' self._last_lsn = ''
self._last_status = {}
self.event = Event() self.event = Event()
def client_path(self, path): def client_path(self, path):
@@ -612,6 +686,10 @@ class AbstractDCS(object):
def history_path(self): def history_path(self):
return self.client_path(self._HISTORY) return self.client_path(self._HISTORY)
@property
def status_path(self):
return self.client_path(self._STATUS)
@property @property
def leader_optime_path(self): def leader_optime_path(self):
return self.client_path(self._LEADER_OPTIME) return self.client_path(self._LEADER_OPTIME)
@@ -682,14 +760,30 @@ class AbstractDCS(object):
self._cluster_valid_till = 0 self._cluster_valid_till = 0
@abc.abstractmethod @abc.abstractmethod
def _write_leader_optime(self, last_operation): def _write_leader_optime(self, last_lsn):
"""write current xlog location into `/optime/leader` key in DCS """write current WAL LSN into `/optime/leader` key in DCS
:param last_operation: absolute xlog location in bytes
:param last_lsn: absolute WAL LSN in bytes
:returns: `!True` on success.""" :returns: `!True` on success."""
def write_leader_optime(self, last_operation): def write_leader_optime(self, last_lsn):
if self._last_leader_operation != last_operation and self._write_leader_optime(last_operation): if self._last_lsn != last_lsn and self._write_leader_optime(last_lsn):
self._last_leader_operation = last_operation self._last_lsn = last_lsn
@abc.abstractmethod
def _write_status(self, value):
"""write current WAL LSN and confirmed_flush_lsn of permanent slots into the `/status` key in DCS
:param value: status serialized in JSON forman
:returns: `!True` on success."""
def write_status(self, value):
if not deep_compare(self._last_status, value) and self._write_status(json.dumps(value, separators=(',', ':'))):
self._last_status = value
cluster = self.cluster
min_version = cluster and cluster.min_version
if min_version and min_version < (2, 1, 0):
self._write_leader_optime(str(value[self._OPTIME]))
@abc.abstractmethod @abc.abstractmethod
def _update_leader(self): def _update_leader(self):
@@ -701,16 +795,20 @@ class AbstractDCS(object):
You have to use CAS (Compare And Swap) operation in order to update leader key, You have to use CAS (Compare And Swap) operation in order to update leader key,
for example for etcd `prevValue` parameter must be used.""" for example for etcd `prevValue` parameter must be used."""
def update_leader(self, last_operation, access_is_restricted=False): def update_leader(self, last_lsn, slots=None):
"""Update leader key (or session) ttl and optime/leader """Update leader key (or session) ttl and optime/leader
:param last_operation: absolute xlog location in bytes :param last_lsn: absolute WAL LSN in bytes
:param slots: dict with permanent slots confirmed_flush_lsn
:returns: `!True` if leader key (or session) has been updated successfully. :returns: `!True` if leader key (or session) has been updated successfully.
If not, `!False` must be returned and current instance would be demoted.""" If not, `!False` must be returned and current instance would be demoted."""
ret = self._update_leader() ret = self._update_leader()
if ret and last_operation: if ret and last_lsn:
self.write_leader_optime(last_operation) status = {self._OPTIME: last_lsn}
if slots:
status['slots'] = slots
self.write_status(status)
return ret return ret
@abc.abstractmethod @abc.abstractmethod
@@ -779,13 +877,13 @@ class AbstractDCS(object):
"""Remove leader key from DCS. """Remove leader key from DCS.
This method should remove leader key if current instance is the leader""" This method should remove leader key if current instance is the leader"""
def delete_leader(self, last_operation=None): def delete_leader(self, last_lsn=None):
"""Update optime/leader and voluntarily remove leader key from DCS. """Update optime/leader and voluntarily remove leader key from DCS.
This method should remove leader key if current instance is the leader. This method should remove leader key if current instance is the leader.
:param last_operation: latest checkpoint location in bytes""" :param last_lsn: latest checkpoint location in bytes"""
if last_operation: if last_lsn:
self.write_leader_optime(last_operation) self.write_leader_optime(last_lsn)
return self._delete_leader() return self._delete_leader()
@abc.abstractmethod @abc.abstractmethod
+27 -8
View File
@@ -111,7 +111,7 @@ class HTTPClient(object):
# According to the documentation a small random amount of additional wait time is added to the # According to the documentation a small random amount of additional wait time is added to the
# supplied maximum wait time to spread out the wake up time of any concurrent requests. This adds # supplied maximum wait time to spread out the wake up time of any concurrent requests. This adds
# up to wait / 16 additional time to the maximum duration. Since our goal is actually getting a # up to wait / 16 additional time to the maximum duration. Since our goal is actually getting a
# response rather read timeout we will add to the timeout a sligtly bigger value. # response rather read timeout we will add to the timeout a slightly bigger value.
kwargs['timeout'] = timeout + max(timeout/15.0, 1) kwargs['timeout'] = timeout + max(timeout/15.0, 1)
else: else:
kwargs['timeout'] = self._read_timeout kwargs['timeout'] = self._read_timeout
@@ -337,9 +337,24 @@ class Consul(AbstractDCS):
history = nodes.get(self._HISTORY) history = nodes.get(self._HISTORY)
history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value']) history = history and TimelineHistory.from_node(history['ModifyIndex'], history['Value'])
# get last leader operation # get last known leader lsn and slots
last_leader_operation = nodes.get(self._LEADER_OPTIME) status = nodes.get(self._STATUS)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['Value']) if status:
try:
status = json.loads(status['Value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['Value']
slots = None
try:
last_lsn = int(last_lsn)
except Exception:
last_lsn = 0
# get list of members # get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -366,9 +381,9 @@ class Consul(AbstractDCS):
sync = nodes.get(self._SYNC) sync = nodes.get(self._SYNC)
sync = SyncState.from_node(sync and sync['ModifyIndex'], sync and sync['Value']) sync = SyncState.from_node(sync and sync['ModifyIndex'], sync and sync['Value'])
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
except NotFound: except NotFound:
return Cluster(None, None, None, None, [], None, None, None) return Cluster(None, None, None, None, [], None, None, None, None)
except Exception: except Exception:
logger.exception('get_cluster') logger.exception('get_cluster')
raise ConsulError('Consul is not responding properly') raise ConsulError('Consul is not responding properly')
@@ -491,8 +506,12 @@ class Consul(AbstractDCS):
return self._client.kv.put(self.config_path, value, cas=index) return self._client.kv.put(self.config_path, value, cas=index)
@catch_consul_errors @catch_consul_errors
def _write_leader_optime(self, last_operation): def _write_leader_optime(self, last_lsn):
return self._client.kv.put(self.leader_optime_path, last_operation) return self._client.kv.put(self.leader_optime_path, last_lsn)
@catch_consul_errors
def _write_status(self, value):
return self._client.kv.put(self.status_path, value)
@catch_consul_errors @catch_consul_errors
def _update_leader(self): def _update_leader(self):
+26 -7
View File
@@ -602,9 +602,24 @@ class Etcd(AbstractEtcd):
history = nodes.get(self._HISTORY) history = nodes.get(self._HISTORY)
history = history and TimelineHistory.from_node(history.modifiedIndex, history.value) history = history and TimelineHistory.from_node(history.modifiedIndex, history.value)
# get last leader operation # get last know leader lsn and slots
last_leader_operation = nodes.get(self._LEADER_OPTIME) status = nodes.get(self._STATUS)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation.value) if status:
try:
status = json.loads(status.value)
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn.value
slots = None
try:
last_lsn = int(last_lsn)
except Exception:
last_lsn = 0
# get list of members # get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -626,9 +641,9 @@ class Etcd(AbstractEtcd):
sync = nodes.get(self._SYNC) sync = nodes.get(self._SYNC)
sync = SyncState.from_node(sync and sync.modifiedIndex, sync and sync.value) sync = SyncState.from_node(sync and sync.modifiedIndex, sync and sync.value)
cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history) cluster = Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
except etcd.EtcdKeyNotFound: except etcd.EtcdKeyNotFound:
cluster = Cluster(None, None, None, None, [], None, None, None) cluster = Cluster(None, None, None, None, [], None, None, None, None)
except Exception as e: except Exception as e:
self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly')) self._handle_exception(e, 'get_cluster', raise_ex=EtcdError('Etcd is not responding properly'))
self._has_failed = False self._has_failed = False
@@ -665,8 +680,12 @@ class Etcd(AbstractEtcd):
return self._client.write(self.config_path, value, prevIndex=index or 0) return self._client.write(self.config_path, value, prevIndex=index or 0)
@catch_etcd_errors @catch_etcd_errors
def _write_leader_optime(self, last_operation): def _write_leader_optime(self, last_lsn):
return self._client.set(self.leader_optime_path, last_operation) return self._client.set(self.leader_optime_path, last_lsn)
@catch_etcd_errors
def _write_status(self, value):
return self._client.set(self.status_path, value)
@catch_etcd_errors @catch_etcd_errors
def _update_leader(self): def _update_leader(self):
+33 -9
View File
@@ -262,6 +262,9 @@ class Etcd3Client(AbstractEtcdClientWithFailover):
return self.api_execute(self.version_prefix + method, self._MPOST, fields) return self.api_execute(self.version_prefix + method, self._MPOST, fields)
def authenticate(self): def authenticate(self):
if self._use_proxies and self._cluster_version is None:
kwargs = self._prepare_common_parameters(1)
self._ensure_version_prefix(self._base_uri, **kwargs)
if self._cluster_version >= (3, 3) and self.username and self.password: if self._cluster_version >= (3, 3) and self.username and self.password:
logger.info('Trying to authenticate on Etcd...') logger.info('Trying to authenticate on Etcd...')
old_token, self._token = self._token, None old_token, self._token = self._token, None
@@ -372,6 +375,7 @@ class KVCache(Thread):
self._config_key = base64_encode(dcs.config_path) self._config_key = base64_encode(dcs.config_path)
self._leader_key = base64_encode(dcs.leader_path) self._leader_key = base64_encode(dcs.leader_path)
self._optime_key = base64_encode(dcs.leader_optime_path) self._optime_key = base64_encode(dcs.leader_optime_path)
self._status_key = base64_encode(dcs.status_path)
self._name = base64_encode(dcs._name) self._name = base64_encode(dcs._name)
self._is_ready = False self._is_ready = False
self._response = None self._response = None
@@ -418,14 +422,15 @@ class KVCache(Thread):
new_value = kv.get('value') new_value = kv.get('value')
value_changed = old_value != new_value and \ value_changed = old_value != new_value and \
(key == self._leader_key or key == self._optime_key and new_value is not None or (key == self._leader_key or key in (self._optime_key, self._status_key) and new_value is not None or
key == self._config_key and old_value is not None and new_value is not None) key == self._config_key and old_value is not None and new_value is not None)
if value_changed: if value_changed:
logger.debug('%s changed from %s to %s', key, old_value, new_value) logger.debug('%s changed from %s to %s', key, old_value, new_value)
# We also want to wake up HA loop on replicas if leader optime was updated # We also want to wake up HA loop on replicas if leader optime (or status key) was updated
if value_changed and (key != self._optime_key or self.get(self._leader_key) != self._name): if value_changed and (key not in (self._optime_key, self._status_key) or
(self.get(self._leader_key) or {}).get('value') != self._name):
self._dcs.event.set() self._dcs.event.set()
def _process_message(self, message): def _process_message(self, message):
@@ -654,9 +659,24 @@ class Etcd3(AbstractEtcd):
history = nodes.get(self._HISTORY) history = nodes.get(self._HISTORY)
history = history and TimelineHistory.from_node(history['mod_revision'], history['value']) history = history and TimelineHistory.from_node(history['mod_revision'], history['value'])
# get last leader operation # get last know leader lsn and slots
last_leader_operation = nodes.get(self._LEADER_OPTIME) status = nodes.get(self._STATUS)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['value']) if status:
try:
status = json.loads(status['value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['value']
slots = None
try:
last_lsn = int(last_lsn)
except Exception:
last_lsn = 0
# get list of members # get list of members
members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] members = [self.member(n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -680,7 +700,7 @@ class Etcd3(AbstractEtcd):
sync = nodes.get(self._SYNC) sync = nodes.get(self._SYNC)
sync = SyncState.from_node(sync and sync['mod_revision'], sync and sync['value']) sync = SyncState.from_node(sync and sync['mod_revision'], sync and sync['value'])
cluster = Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history) cluster = Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
except UnsupportedEtcdVersion: except UnsupportedEtcdVersion:
raise raise
except Exception as e: except Exception as e:
@@ -741,8 +761,12 @@ class Etcd3(AbstractEtcd):
return self._client.put(self.config_path, value, mod_revision=index) return self._client.put(self.config_path, value, mod_revision=index)
@catch_etcd_errors @catch_etcd_errors
def _write_leader_optime(self, last_operation): def _write_leader_optime(self, last_lsn):
return self._client.put(self.leader_optime_path, last_operation) return self._client.put(self.leader_optime_path, last_lsn)
@catch_etcd_errors
def _write_status(self, value):
return self._client.put(self.status_path, value)
@catch_etcd_errors @catch_etcd_errors
def _update_leader(self): def _update_leader(self):
+26 -13
View File
@@ -724,9 +724,19 @@ class Kubernetes(AbstractDCS):
self._leader_resource_version = metadata.resource_version if metadata else None self._leader_resource_version = metadata.resource_version if metadata else None
annotations = metadata and metadata.annotations or {} annotations = metadata and metadata.annotations or {}
# get last leader operation # get last known leader lsn
last_leader_operation = annotations.get(self._OPTIME) last_lsn = annotations.get(self._OPTIME)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation) try:
last_lsn = 0 if last_lsn is None else int(last_lsn)
except Exception:
last_lsn = 0
# get permanent slots state (confirmed_flush_lsn)
slots = annotations.get('slots')
try:
slots = slots and json.loads(slots)
except Exception:
slots = None
# get leader # get leader
leader_record = {n: annotations.get(n) for n in (self._LEADER, 'acquireTime', leader_record = {n: annotations.get(n) for n in (self._LEADER, 'acquireTime',
@@ -760,7 +770,7 @@ class Kubernetes(AbstractDCS):
metadata = sync and sync.metadata metadata = sync and sync.metadata
sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations) sync = SyncState.from_node(metadata and metadata.resource_version, metadata and metadata.annotations)
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
except Exception: except Exception:
logger.exception('get_cluster') logger.exception('get_cluster')
raise KubernetesError('Kubernetes API is not responding properly') raise KubernetesError('Kubernetes API is not responding properly')
@@ -881,7 +891,10 @@ class Kubernetes(AbstractDCS):
return logger.exception('create_config_service failed') return logger.exception('create_config_service failed')
self._should_create_config_service = False self._should_create_config_service = False
def _write_leader_optime(self, last_operation): def _write_leader_optime(self, last_lsn):
"""Unused"""
def _write_status(self, value):
"""Unused""" """Unused"""
def _update_leader(self): def _update_leader(self):
@@ -931,7 +944,7 @@ class Kubernetes(AbstractDCS):
return self.patch_or_create(self.leader_path, annotations, kind_resource_version, ips=ips, retry=_retry) return self.patch_or_create(self.leader_path, annotations, kind_resource_version, ips=ips, retry=_retry)
def update_leader(self, last_operation, access_is_restricted=False): def update_leader(self, last_lsn, slots=None):
kind = self._kinds.get(self.leader_path) kind = self._kinds.get(self.leader_path)
kind_annotations = kind and kind.metadata.annotations or {} kind_annotations = kind and kind.metadata.annotations or {}
@@ -943,12 +956,12 @@ class Kubernetes(AbstractDCS):
annotations = {self._LEADER: self._name, 'ttl': str(self._ttl), 'renewTime': now, annotations = {self._LEADER: self._name, 'ttl': str(self._ttl), 'renewTime': now,
'acquireTime': leader_observed_record.get('acquireTime') or now, 'acquireTime': leader_observed_record.get('acquireTime') or now,
'transitions': leader_observed_record.get('transitions') or '0'} 'transitions': leader_observed_record.get('transitions') or '0'}
if last_operation: if last_lsn:
annotations[self._OPTIME] = last_operation annotations[self._OPTIME] = str(last_lsn)
annotations['slots'] = json.dumps(slots) if slots else None
resource_version = kind and kind.metadata.resource_version resource_version = kind and kind.metadata.resource_version
ips = [] if access_is_restricted else self.__ips return self._update_leader_with_retry(annotations, resource_version, self.__ips)
return self._update_leader_with_retry(annotations, resource_version, ips)
def attempt_to_acquire_leader(self, permanent=False): def attempt_to_acquire_leader(self, permanent=False):
now = datetime.datetime.now(tzutc).isoformat() now = datetime.datetime.now(tzutc).isoformat()
@@ -1024,12 +1037,12 @@ class Kubernetes(AbstractDCS):
def _delete_leader(self): def _delete_leader(self):
"""Unused""" """Unused"""
def delete_leader(self, last_operation=None): def delete_leader(self, last_lsn=None):
kind = self._kinds.get(self.leader_path) kind = self._kinds.get(self.leader_path)
if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name: if kind and (kind.metadata.annotations or {}).get(self._LEADER) == self._name:
annotations = {self._LEADER: None} annotations = {self._LEADER: None}
if last_operation: if last_lsn:
annotations[self._OPTIME] = last_operation annotations[self._OPTIME] = last_lsn
self.patch_or_create(self.leader_path, annotations, kind.metadata.resource_version, True, False, []) self.patch_or_create(self.leader_path, annotations, kind.metadata.resource_version, True, False, [])
self.reset_cluster() self.reset_cluster()
+135 -134
View File
@@ -4,130 +4,124 @@ import os
import threading import threading
import time import time
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from ..utils import validate_directory
from pysyncobj import SyncObj, SyncObjConf, replicated, FAIL_REASON from pysyncobj import SyncObj, SyncObjConf, replicated, FAIL_REASON
from pysyncobj.transport import Node, TCPTransport, CONNECTION_STATE from pysyncobj.dns_resolver import globalDnsResolver
from pysyncobj.node import TCPNode
from pysyncobj.transport import TCPTransport, CONNECTION_STATE
from pysyncobj.utility import TcpUtility
from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from ..utils import validate_directory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MessageNode(Node): class _TCPTransport(TCPTransport):
def __init__(self, address):
self.address = address
class UtilityTransport(TCPTransport):
def __init__(self, syncObj, selfNode, otherNodes): def __init__(self, syncObj, selfNode, otherNodes):
super(UtilityTransport, self).__init__(syncObj, selfNode, otherNodes) super(_TCPTransport, self).__init__(syncObj, selfNode, otherNodes)
self._selfIsReadonlyNode = False self.setOnUtilityMessageCallback('members', syncObj.getMembers)
def _connectIfNecessarySingle(self, node): def _connectIfNecessarySingle(self, node):
pass try:
return super(_TCPTransport, self)._connectIfNecessarySingle(node)
def connectionState(self, node): except Exception as e:
return self._connections[node].state logger.debug('Connection to %s failed: %r', node, e)
return False
def isDisconnected(self, node):
return self.connectionState(node) == CONNECTION_STATE.DISCONNECTED
def connectIfRequiredSingle(self, node):
if self.isDisconnected(node):
return self._connections[node].connect(node.ip, node.port)
def disconnectSingle(self, node):
self._connections[node].disconnect()
class SyncObjUtility(SyncObj): def resolve_host(self):
return globalDnsResolver().resolve(self.host)
setattr(TCPNode, 'ip', property(resolve_host))
class SyncObjUtility(object):
def __init__(self, otherNodes, conf): def __init__(self, otherNodes, conf):
autoTick = conf.autoTick self._nodes = otherNodes
conf.autoTick = False self._utility = TcpUtility(conf.password)
super(SyncObjUtility, self).__init__(None, otherNodes, conf, transportClass=UtilityTransport)
conf.autoTick = autoTick
self._SyncObj__transport.setOnMessageReceivedCallback(self._onMessageReceived)
self.__result = None
def setPartnerNode(self, partner): def executeCommand(self, command):
self.__node = partner try:
return self._utility.executeCommand(self.__node, command)
except Exception:
return None
def sendMessage(self, message): def getMembers(self):
# Abuse the fact that node address is send as a first message for self.__node in self._nodes:
self._SyncObj__transport._selfNode = MessageNode(message) response = self.executeCommand(['members'])
self._SyncObj__transport.connectIfRequiredSingle(self.__node) if response:
while not self._SyncObj__transport.isDisconnected(self.__node): return [member['addr'] for member in response]
self._poller.poll(0.5)
return self.__result
def _onMessageReceived(self, _, message):
self.__result = message
self._SyncObj__transport.disconnectSingle(self.__node)
class MyTCPTransport(TCPTransport):
def _onIncomingMessageReceived(self, conn, message):
if self._syncObj.encryptor and not conn.sendRandKey:
conn.sendRandKey = message
conn.recvRandKey = os.urandom(32)
conn.send(conn.recvRandKey)
return
# Utility messages
if isinstance(message, list) and message[0] == 'members':
conn.send(self._syncObj._get_members())
return True
return super(MyTCPTransport, self)._onIncomingMessageReceived(conn, message)
class DynMemberSyncObj(SyncObj): class DynMemberSyncObj(SyncObj):
def __init__(self, selfAddress, partnerAddrs, conf): def __init__(self, selfAddress, partnerAddrs, conf):
add_self = False self.__early_apply_local_log = selfAddress is not None
self.applied_local_log = False
utility = SyncObjUtility(partnerAddrs, conf) utility = SyncObjUtility(partnerAddrs, conf)
for node in utility._SyncObj__otherNodes: members = utility.getMembers()
utility.setPartnerNode(node) add_self = members and selfAddress not in members
response = utility.sendMessage(['members'])
if response: partnerAddrs = [member for member in (members or partnerAddrs) if member != selfAddress]
partnerAddrs = [member['addr'] for member in response if member['addr'] != selfAddress]
add_self = selfAddress and len(partnerAddrs) == len(response) super(DynMemberSyncObj, self).__init__(selfAddress, partnerAddrs, conf, transportClass=_TCPTransport)
break
super(DynMemberSyncObj, self).__init__(selfAddress, partnerAddrs, conf, transportClass=MyTCPTransport)
if add_self: if add_self:
threading.Thread(target=utility.sendMessage, args=(['add', selfAddress],)).start() thread = threading.Thread(target=utility.executeCommand, args=(['add', selfAddress],))
thread.daemon = True
thread.start()
def _get_members(self): def getMembers(self, args, callback):
ret = [{'addr': node.id, 'leader': node == self._getLeader(), callback([{'addr': node.id, 'leader': node == self._getLeader(), 'status': CONNECTION_STATE.CONNECTED
'status': CONNECTION_STATE.CONNECTED if node in self._SyncObj__connectedNodes if self.isNodeConnected(node) else CONNECTION_STATE.DISCONNECTED} for node in self.otherNodes] +
else CONNECTION_STATE.DISCONNECTED} for node in self._SyncObj__otherNodes] [{'addr': self.selfNode.id, 'leader': self._isLeader(), 'status': CONNECTION_STATE.CONNECTED}], None)
ret.append({'addr': self._SyncObj__selfNode.id, 'leader': self._isLeader(),
'status': CONNECTION_STATE.CONNECTED})
return ret
def _SyncObj__doChangeCluster(self, request, reverse=False): def _onTick(self, timeToWait=0.0):
ret = False super(DynMemberSyncObj, self)._onTick(timeToWait)
if not self._SyncObj__selfNode or request[0] != 'add' or reverse or request[1] != self._SyncObj__selfNode.id:
ret = super(DynMemberSyncObj, self)._SyncObj__doChangeCluster(request, reverse) # The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
if ret: # In some cases for us it is safe to "signal" the Raft object when the local log is fully applied.
self.forceLogCompaction() # We are using the `applied_local_log` property for that, but not calling the callback function.
return ret if self.__early_apply_local_log and not self.applied_local_log and self.raftLastApplied == self.raftCommitIndex:
self.applied_local_log = True
class KVStoreTTL(DynMemberSyncObj): class KVStoreTTL(DynMemberSyncObj):
def __init__(self, selfAddress, partnerAddrs, conf, on_set=None, on_delete=None): def __init__(self, on_ready, on_set, on_delete, **config):
self.__thread = None
self.__on_set = on_set self.__on_set = on_set
self.__on_delete = on_delete self.__on_delete = on_delete
self.__limb = {} self.__limb = {}
self.__retry_timeout = None self.__retry_timeout = None
self.__early_apply_local_log = selfAddress is not None
self.applied_local_log = False self_addr = config.get('self_addr')
super(KVStoreTTL, self).__init__(selfAddress, partnerAddrs, conf) partner_addrs = set(config.get('partner_addrs', []))
if config.get('patronictl'):
if self_addr:
partner_addrs.add(self_addr)
self_addr = None
# Create raft data_dir if necessary
raft_data_dir = config.get('data_dir', '')
if raft_data_dir != '':
validate_directory(raft_data_dir)
file_template = (self_addr or '')
file_template = file_template.replace(':', '_') if os.name == 'nt' else file_template
file_template = os.path.join(raft_data_dir, file_template)
conf = SyncObjConf(password=config.get('password'), autoTick=False, appendEntriesUseBatch=False,
bindAddress=config.get('bind_addr'), dnsFailCacheTime=(config.get('loop_wait') or 10),
dnsCacheTime=(config.get('ttl') or 30), commandsWaitLeader=config.get('commandsWaitLeader'),
fullDumpFile=(file_template + '.dump' if self_addr else None),
journalFile=(file_template + '.journal' if self_addr else None),
onReady=on_ready, dynamicMembershipChange=True)
super(KVStoreTTL, self).__init__(self_addr, partner_addrs, conf)
self.__data = {} self.__data = {}
@staticmethod @staticmethod
@@ -174,7 +168,7 @@ class KVStoreTTL(DynMemberSyncObj):
if old_value and old_value['created'] != value['created']: if old_value and old_value['created'] != value['created']:
value['created'] = value['updated'] value['created'] = value['updated']
value['index'] = self._SyncObj__raftLastApplied + 1 value['index'] = self.raftLastApplied + 1
self.__data[key] = value self.__data[key] = value
if self.__on_set: if self.__on_set:
@@ -241,27 +235,29 @@ class KVStoreTTL(DynMemberSyncObj):
return {k: v for k, v in self.__data.items() if k.startswith(key)} return {k: v for k, v in self.__data.items() if k.startswith(key)}
def _onTick(self, timeToWait=0.0): def _onTick(self, timeToWait=0.0):
# The SyncObj starts applying the local log only when there is at least one node connected.
# We want to change this behavior and apply the local log even when there is nobody except us.
# It gives us at least some picture about the last known cluster state.
if self.__early_apply_local_log and not self.applied_local_log and self._SyncObj__needLoadDumpFile:
self._SyncObj__raftCommitIndex = self._SyncObj__getCurrentLogIndex()
self._SyncObj__raftCurrentTerm = self._SyncObj__getCurrentLogTerm()
super(KVStoreTTL, self)._onTick(timeToWait) super(KVStoreTTL, self)._onTick(timeToWait)
# The SyncObj calls onReady callback only when cluster got the leader and is ready for writes.
# In some cases for us it is safe to "signal" the Raft object when the local log is fully applied.
# We are using the `applied_local_log` property for that, but not calling the callback function.
if self.__early_apply_local_log and not self.applied_local_log and self._SyncObj__raftCommitIndex != 1 and \
self._SyncObj__raftLastApplied == self._SyncObj__raftCommitIndex:
self.applied_local_log = True
if self._isLeader(): if self._isLeader():
self.__expire_keys() self.__expire_keys()
else: else:
self.__limb.clear() self.__limb.clear()
def _autoTickThread(self):
self.__destroying = False
while not self.__destroying:
self.doTick(self.conf.autoTickPeriod)
def startAutoTick(self):
self.__thread = threading.Thread(target=self._autoTickThread)
self.__thread.daemon = True
self.__thread.start()
def destroy(self):
if self.__thread:
self.__destroying = True
self.__thread.join()
super(KVStoreTTL, self).destroy()
class Raft(AbstractDCS): class Raft(AbstractDCS):
@@ -269,41 +265,24 @@ class Raft(AbstractDCS):
super(Raft, self).__init__(config) super(Raft, self).__init__(config)
self._ttl = int(config.get('ttl') or 30) self._ttl = int(config.get('ttl') or 30)
self_addr = config.get('self_addr')
partner_addrs = config.get('partner_addrs', [])
if self._ctl:
if self_addr:
partner_addrs.append(self_addr)
self_addr = None
# Create raft data_dir if necessary
raft_data_dir = config.get('data_dir', '')
if raft_data_dir != '':
validate_directory(raft_data_dir)
ready_event = threading.Event() ready_event = threading.Event()
file_template = os.path.join(config.get('data_dir', ''), (self_addr or '')) self._sync_obj = KVStoreTTL(ready_event.set, self._on_set, self._on_delete, commandsWaitLeader=False, **config)
conf = SyncObjConf(password=config.get('password'), appendEntriesUseBatch=False, self._sync_obj.startAutoTick()
bindAddress=config.get('bind_addr'), commandsWaitLeader=False,
fullDumpFile=(file_template + '.dump' if self_addr else None),
journalFile=(file_template + '.journal' if self_addr else None),
onReady=ready_event.set, dynamicMembershipChange=True)
self._sync_obj = KVStoreTTL(self_addr, partner_addrs, conf, self._on_set, self._on_delete)
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.isSet() or self._sync_obj.applied_local_log:
break break
else: else:
logger.info('waiting on raft') logger.info('waiting on raft')
self._sync_obj.forceLogCompaction()
self.set_retry_timeout(int(config.get('retry_timeout') or 10)) self.set_retry_timeout(int(config.get('retry_timeout') or 10))
def _on_set(self, key, value): def _on_set(self, key, value):
leader = (self._sync_obj.get(self.leader_path) or {}).get('value') leader = (self._sync_obj.get(self.leader_path) or {}).get('value')
if key == value['created'] == value['updated'] and \ if key == value['created'] == value['updated'] and \
(key.startswith(self.members_path) or key == self.leader_path and leader != self._name) or \ (key.startswith(self.members_path) or key == self.leader_path and leader != self._name) or \
key == self.leader_optime_path and leader != self._name or key in (self.config_path, self.sync_path): key in (self.leader_optime_path, self.status_path) and leader != self._name or \
key in (self.config_path, self.sync_path):
self.event.set() self.event.set()
def _on_delete(self, key): def _on_delete(self, key):
@@ -320,6 +299,10 @@ class Raft(AbstractDCS):
def set_retry_timeout(self, retry_timeout): def set_retry_timeout(self, retry_timeout):
self._sync_obj.set_retry_timeout(retry_timeout) self._sync_obj.set_retry_timeout(retry_timeout)
def reload_config(self, config):
super(Raft, self).reload_config(config)
globalDnsResolver().setTimeouts(self.ttl, self.loop_wait)
@staticmethod @staticmethod
def member(key, value): def member(key, value):
return Member.from_node(value['index'], os.path.basename(key), None, value['value']) return Member.from_node(value['index'], os.path.basename(key), None, value['value'])
@@ -328,7 +311,7 @@ class Raft(AbstractDCS):
prefix = self.client_path('') prefix = self.client_path('')
response = self._sync_obj.get(prefix, recursive=True) response = self._sync_obj.get(prefix, recursive=True)
if not response: if not response:
return Cluster(None, None, None, None, [], None, None, None) return Cluster(None, None, None, None, [], None, None, None, None)
nodes = {os.path.relpath(key, prefix).replace('\\', '/'): value for key, value in response.items()} nodes = {os.path.relpath(key, prefix).replace('\\', '/'): value for key, value in response.items()}
# get initialize flag # get initialize flag
@@ -343,9 +326,24 @@ class Raft(AbstractDCS):
history = nodes.get(self._HISTORY) history = nodes.get(self._HISTORY)
history = history and TimelineHistory.from_node(history['index'], history['value']) history = history and TimelineHistory.from_node(history['index'], history['value'])
# get last leader operation # get last know leader lsn and slots
last_leader_operation = nodes.get(self._LEADER_OPTIME) status = nodes.get(self._STATUS)
last_leader_operation = 0 if last_leader_operation is None else int(last_leader_operation['value']) if status:
try:
status = json.loads(status['value'])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = nodes.get(self._LEADER_OPTIME)
last_lsn = last_lsn and last_lsn['value']
slots = None
try:
last_lsn = int(last_lsn)
except Exception:
last_lsn = 0
# get list of members # get list of members
members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1] members = [self.member(k, n) for k, n in nodes.items() if k.startswith(self._MEMBERS) and k.count('/') == 1]
@@ -366,10 +364,13 @@ class Raft(AbstractDCS):
sync = nodes.get(self._SYNC) sync = nodes.get(self._SYNC)
sync = SyncState.from_node(sync and sync['index'], sync and sync['value']) sync = SyncState.from_node(sync and sync['index'], sync and sync['value'])
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
def _write_leader_optime(self, last_operation): def _write_leader_optime(self, last_lsn):
return self._sync_obj.set(self.leader_optime_path, last_operation, timeout=1) return self._sync_obj.set(self.leader_optime_path, last_lsn, timeout=1)
def _write_status(self, value):
return self._sync_obj.set(self.status_path, value, timeout=1)
def _update_leader(self): def _update_leader(self):
ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, prevValue=self._name) ret = self._sync_obj.set(self.leader_path, self._name, ttl=self._ttl, prevValue=self._name)
+71 -34
View File
@@ -4,11 +4,13 @@ import select
import time import time
from kazoo.client import KazooClient, KazooState, KazooRetry from kazoo.client import KazooClient, KazooState, KazooRetry
from kazoo.exceptions import NoNodeError, NodeExistsError from kazoo.exceptions import NoNodeError, NodeExistsError, SessionExpiredError
from kazoo.handlers.threading import SequentialThreadingHandler from kazoo.handlers.threading import SequentialThreadingHandler
from patroni.dcs import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory from kazoo.protocol.states import KeeperState
from patroni.exceptions import DCSError
from patroni.utils import deep_compare from . import AbstractDCS, ClusterConfig, Cluster, Failover, Leader, Member, SyncState, TimelineHistory
from ..exceptions import DCSError
from ..utils import deep_compare
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,6 +56,20 @@ class PatroniSequentialThreadingHandler(SequentialThreadingHandler):
raise select.error(9, str(e)) raise select.error(9, str(e))
class PatroniKazooClient(KazooClient):
def _call(self, request, async_object):
# Before kazoo==2.7.0 it wasn't possible to send requests to zookeeper if
# the connection is in the SUSPENDED state and Patroni was strongly relying on it.
# The https://github.com/python-zk/kazoo/pull/588 changed it, and now such requests are queued.
# We override the `_call()` method in order to keep the old behavior.
if self._state == KeeperState.CONNECTING:
async_object.set_exception(SessionExpiredError())
return False
return super(PatroniKazooClient, self)._call(request, async_object)
class ZooKeeper(AbstractDCS): class ZooKeeper(AbstractDCS):
def __init__(self, config): def __init__(self, config):
@@ -67,14 +83,14 @@ 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}
self._client = KazooClient(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(deadline=config['retry_timeout'], sleep_func=time.sleep), command_retry=KazooRetry(max_delay=1, max_tries=-1,
max_delay=1, max_tries=-1, sleep_func=time.sleep), **kwargs) deadline=config['retry_timeout'], sleep_func=time.sleep), **kwargs)
self._client.add_listener(self.session_listener) self._client.add_listener(self.session_listener)
self._fetch_cluster = True self._fetch_cluster = True
self._fetch_optime = True self._fetch_status = True
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
@@ -100,13 +116,13 @@ class ZooKeeper(AbstractDCS):
if state in [KazooState.SUSPENDED, KazooState.LOST]: if state in [KazooState.SUSPENDED, KazooState.LOST]:
self.cluster_watcher(None) self.cluster_watcher(None)
def optime_watcher(self, event): def status_watcher(self, event):
self._fetch_optime = True self._fetch_status = True
self.event.set() self.event.set()
def cluster_watcher(self, event): def cluster_watcher(self, event):
self._fetch_cluster = True self._fetch_cluster = True
self.optime_watcher(event) self.status_watcher(event)
def reload_config(self, config): def reload_config(self, config):
self.set_retry_timeout(config['retry_timeout']) self.set_retry_timeout(config['retry_timeout'])
@@ -151,11 +167,29 @@ class ZooKeeper(AbstractDCS):
except NoNodeError: except NoNodeError:
return None return None
def get_leader_optime(self, leader): def get_status(self, leader):
watch = self.optime_watcher if not leader or leader.name != self._name else None watch = self.status_watcher if not leader or leader.name != self._name else None
optime = self.get_node(self.leader_optime_path, watch)
self._fetch_optime = False status = self.get_node(self.status_path, watch)
return optime and int(optime[0]) or 0 if status:
try:
status = json.loads(status[0])
last_lsn = status.get(self._OPTIME)
slots = status.get('slots')
except Exception:
slots = last_lsn = None
else:
last_lsn = self.get_node(self.leader_optime_path, watch)
last_lsn = last_lsn and last_lsn[0]
slots = None
try:
last_lsn = int(last_lsn)
except Exception:
last_lsn = 0
self._fetch_status = False
return last_lsn, slots
@staticmethod @staticmethod
def member(name, value, znode): def member(name, value, znode):
@@ -167,11 +201,10 @@ class ZooKeeper(AbstractDCS):
except NoNodeError: except NoNodeError:
return [] return []
def load_members(self, sync_standby): def load_members(self):
members = [] members = []
for member in self.get_children(self.members_path, self.cluster_watcher): for member in self.get_children(self.members_path, self.cluster_watcher):
watch = member in sync_standby and self.cluster_watcher or None data = self.get_node(self.members_path + member)
data = self.get_node(self.members_path + member, watch)
if data is not None: if data is not None:
members.append(self.member(member, *data)) members.append(self.member(member, *data))
return members return members
@@ -199,8 +232,7 @@ class ZooKeeper(AbstractDCS):
sync = SyncState.from_node(sync and sync[1].version, sync and sync[0]) sync = SyncState.from_node(sync and sync[1].version, sync and sync[0])
# get list of members # get list of members
sync_standby = sync.leader == self._name and sync.members or [] members = self.load_members() if self._MEMBERS[:-1] in nodes else []
members = self.load_members(sync_standby) if self._MEMBERS[:-1] in nodes else []
# get leader # get leader
leader = self.get_node(self.leader_path) if self._LEADER in nodes else None leader = self.get_node(self.leader_path) if self._LEADER in nodes else None
@@ -218,14 +250,14 @@ class ZooKeeper(AbstractDCS):
leader = Leader(leader[1].version, leader[1].ephemeralOwner, member) leader = Leader(leader[1].version, leader[1].ephemeralOwner, member)
self._fetch_cluster = member.index == -1 self._fetch_cluster = member.index == -1
# get last leader operation # get last known leader lsn and slots
last_leader_operation = self._OPTIME in nodes and self.get_leader_optime(leader) last_lsn, slots = self.get_status(leader)
# failover key # failover key
failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None failover = self.get_node(self.failover_path, watch=self.cluster_watcher) if self._FAILOVER in nodes else None
failover = failover and Failover.from_node(failover[1].version, failover[0]) failover = failover and Failover.from_node(failover[1].version, failover[0])
return Cluster(initialize, config, leader, last_leader_operation, members, failover, sync, history) return Cluster(initialize, config, leader, last_lsn, members, failover, sync, history, slots)
def _load_cluster(self): def _load_cluster(self):
cluster = self.cluster cluster = self.cluster
@@ -236,13 +268,15 @@ 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')
# Optime ZNode was updated or doesn't exist and we are not leader # The /status ZNode was updated or doesn't exist and we are not leader
elif (self._fetch_optime and not self._fetch_cluster or not cluster.last_leader_operation) and\ 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\
not (cluster.leader and cluster.leader.name == self._name): not (cluster.leader and cluster.leader.name == self._name):
try: try:
optime = self.get_leader_optime(cluster.leader) last_lsn, slots = self.get_status(cluster.leader)
cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, optime, self.event.clear()
cluster.members, cluster.failover, cluster.sync, cluster.history) cluster = Cluster(cluster.initialize, cluster.config, cluster.leader, last_lsn,
cluster.members, cluster.failover, cluster.sync, cluster.history, slots)
except Exception: except Exception:
pass pass
return cluster return cluster
@@ -336,8 +370,11 @@ class ZooKeeper(AbstractDCS):
def take_leader(self): def take_leader(self):
return self.attempt_to_acquire_leader() return self.attempt_to_acquire_leader()
def _write_leader_optime(self, last_operation): def _write_leader_optime(self, last_lsn):
return self._set_or_create(self.leader_optime_path, last_operation) return self._set_or_create(self.leader_optime_path, last_lsn)
def _write_status(self, value):
return self._set_or_create(self.status_path, value)
def _update_leader(self): def _update_leader(self):
return True return True
@@ -373,7 +410,7 @@ class ZooKeeper(AbstractDCS):
return self.set_sync_state_value("{}", index) return self.set_sync_state_value("{}", index)
def watch(self, leader_index, timeout): def watch(self, leader_index, timeout):
ret = super(ZooKeeper, self).watch(leader_index, timeout) ret = super(ZooKeeper, self).watch(leader_index, timeout + 0.5)
if ret and not self._fetch_optime: if ret and not self._fetch_status:
self._fetch_cluster = True self._fetch_cluster = True
return ret or self._fetch_cluster return ret or self._fetch_cluster
+63 -51
View File
@@ -66,7 +66,6 @@ class Ha(object):
self.old_cluster = None self.old_cluster = None
self._is_leader = False self._is_leader = False
self._is_leader_lock = RLock() self._is_leader_lock = RLock()
self._leader_access_is_restricted = False
self._was_paused = False self._was_paused = False
self._leader_timeline = None self._leader_timeline = None
self.recovering = False self.recovering = False
@@ -85,7 +84,7 @@ class Ha(object):
self._disable_sync = 0 self._disable_sync = 0
# We need following property to avoid shutdown of postgres when join of Patroni to the postgres # We need following property to avoid shutdown of postgres when join of Patroni to the postgres
# already running as replica was aborted due to cluster not beeing initialized in DCS. # already running as replica was aborted due to cluster not being initialized in DCS.
self._join_aborted = False self._join_aborted = False
# used only in backoff after failing a pre_promote script # used only in backoff after failing a pre_promote script
@@ -121,16 +120,12 @@ class Ha(object):
def is_leader(self): def is_leader(self):
with self._is_leader_lock: with self._is_leader_lock:
return self._is_leader > time.time() and not self._leader_access_is_restricted return self._is_leader > time.time()
def set_is_leader(self, value): def set_is_leader(self, value):
with self._is_leader_lock: with self._is_leader_lock:
self._is_leader = time.time() + self.dcs.ttl if value else 0 self._is_leader = time.time() + self.dcs.ttl if value else 0
def set_leader_access_is_restricted(self, value):
with self._is_leader_lock:
self._leader_access_is_restricted = value
def load_cluster_from_dcs(self): def load_cluster_from_dcs(self):
cluster = self.dcs.get_cluster() cluster = self.dcs.get_cluster()
@@ -145,20 +140,20 @@ class Ha(object):
self._leader_timeline = None if cluster.is_unlocked() else cluster.leader.timeline self._leader_timeline = None if cluster.is_unlocked() else cluster.leader.timeline
def acquire_lock(self): def acquire_lock(self):
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
ret = self.dcs.attempt_to_acquire_leader() ret = self.dcs.attempt_to_acquire_leader()
self.set_is_leader(ret) self.set_is_leader(ret)
return ret return ret
def update_lock(self, write_leader_optime=False): def update_lock(self, write_leader_optime=False):
last_operation = None last_lsn = slots = None
if write_leader_optime: if write_leader_optime:
try: try:
last_operation = self.state_handler.last_operation() last_lsn = self.state_handler.last_operation()
slots = self.state_handler.slots()
except Exception: except Exception:
logger.exception('Exception when called state_handler.last_operation()') logger.exception('Exception when called state_handler.last_operation()')
try: try:
ret = self.dcs.update_leader(last_operation, self._leader_access_is_restricted) ret = self.dcs.update_leader(last_lsn, slots)
except Exception: except Exception:
logger.exception('Unexpected exception raised from update_leader, please report it as a BUG') logger.exception('Unexpected exception raised from update_leader, please report it as a BUG')
ret = False ret = False
@@ -409,7 +404,7 @@ class Ha(object):
self.state_handler.set_role('replica') self.state_handler.set_role('replica')
if not node_to_follow: if not node_to_follow:
return 'no action' return 'no action. I am ({0})'.format(self.state_handler.name)
elif is_leader: elif is_leader:
self.demote('immediate-nolock') self.demote('immediate-nolock')
return demote_reason return demote_reason
@@ -422,6 +417,9 @@ class Ha(object):
if msg: if msg:
return msg return msg
if not self.is_paused():
self.state_handler.handle_parameter_change()
role = 'standby_leader' if isinstance(node_to_follow, RemoteMember) and self.has_lock(False) else 'replica' role = 'standby_leader' if isinstance(node_to_follow, RemoteMember) and self.has_lock(False) else 'replica'
# It might happen that leader key in the standby cluster references non-exiting member. # It might happen that leader key in the standby cluster references non-exiting member.
# In this case it is safe to continue running without changing recovery.conf # In this case it is safe to continue running without changing recovery.conf
@@ -613,8 +611,6 @@ class Ha(object):
return 'Postponing promotion because synchronous replication state was updated by somebody else' return 'Postponing promotion because synchronous replication state was updated by somebody else'
self.state_handler.config.set_synchronous_standby(['*'] if self.is_synchronous_mode_strict() else []) self.state_handler.config.set_synchronous_standby(['*'] if self.is_synchronous_mode_strict() else [])
if self.state_handler.role != 'master': if self.state_handler.role != 'master':
self.set_leader_access_is_restricted(self.cluster.has_permanent_logical_slots(self.state_handler.name))
def on_success(): def on_success():
self._rewind.reset_state() self._rewind.reset_state()
logger.info("cleared rewind state after becoming the leader") logger.info("cleared rewind state after becoming the leader")
@@ -622,8 +618,7 @@ class Ha(object):
with self._async_response: with self._async_response:
self._async_response.reset() self._async_response.reset()
self._async_executor.try_run_async('promote', self.state_handler.promote, self._async_executor.try_run_async('promote', self.state_handler.promote,
args=(self.dcs.loop_wait, self._async_response, on_success, args=(self.dcs.loop_wait, self._async_response, on_success))
self._leader_access_is_restricted))
return promote_message return promote_message
def fetch_node_status(self, member): def fetch_node_status(self, member):
@@ -653,14 +648,13 @@ class Ha(object):
:param wal_position: Current wal position. :param wal_position: Current wal position.
:returns True when node is lagging :returns True when node is lagging
""" """
lag = (self.cluster.last_leader_operation or 0) - wal_position lag = (self.cluster.last_lsn or 0) - wal_position
return lag > self.patroni.config.get('maximum_lag_on_failover', 0) return lag > self.patroni.config.get('maximum_lag_on_failover', 0)
def _is_healthiest_node(self, members, check_replication_lag=True): def _is_healthiest_node(self, members, check_replication_lag=True):
"""This method tries to determine whether I am healthy enough to became a new leader candidate or not.""" """This method tries to determine whether I am healthy enough to became a new leader candidate or not."""
# We don't call `last_operation()` here because it returns a string my_wal_position = self.state_handler.last_operation()
_, my_wal_position, _ = self.state_handler.timeline_wal_position()
if check_replication_lag and self.is_lagging(my_wal_position): if check_replication_lag and self.is_lagging(my_wal_position):
logger.info('My wal position exceeds maximum replication lag') logger.info('My wal position exceeds maximum replication lag')
return False # Too far behind last reported wal position on master return False # Too far behind last reported wal position on master
@@ -802,13 +796,13 @@ class Ha(object):
return self._is_healthiest_node(members.values()) return self._is_healthiest_node(members.values())
def _delete_leader(self, last_operation=None): def _delete_leader(self, last_lsn=None):
self.set_is_leader(False) self.set_is_leader(False)
self.dcs.delete_leader(last_operation) self.dcs.delete_leader(last_lsn)
self.dcs.reset_cluster() self.dcs.reset_cluster()
def release_leader_key_voluntarily(self, last_operation=None): def release_leader_key_voluntarily(self, last_lsn=None):
self._delete_leader(last_operation) self._delete_leader(last_lsn)
self.touch_member() self.touch_member()
logger.info("Leader key released") logger.info("Leader key released")
@@ -989,11 +983,6 @@ class Ha(object):
self._delete_leader() self._delete_leader()
return 'removed leader lock because postgres is not running as master' return 'removed leader lock because postgres is not running as master'
if self.state_handler.is_leader() and self._leader_access_is_restricted:
self.state_handler.slots_handler.sync_replication_slots(self.cluster)
self.state_handler.call_nowait(ACTION_ON_ROLE_CHANGE)
self.set_leader_access_is_restricted(False)
if self.update_lock(True): if self.update_lock(True):
msg = self.process_manual_failover_from_leader() msg = self.process_manual_failover_from_leader()
if msg is not None: if msg is not None:
@@ -1006,14 +995,14 @@ 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 the standby leader with the lock' \ 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 the leader with the lock', '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:
# Either there is no connection to DCS or someone else acquired the lock # Either there is no connection to DCS or someone else acquired the lock
@@ -1026,12 +1015,15 @@ class Ha(object):
else: else:
return 'not promoting because failed to update leader lock in DCS' return 'not promoting because failed to update leader lock in DCS'
else: else:
logger.info('does not have lock') logger.debug('does not have lock')
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 master in standby cluster', return self.follow('cannot be a real primary in a standby cluster',
'no action. i am a secondary and i am following a standby leader', refresh=False) 'no action. I am a secondary ({0}) and following a standby leader ({1})'.format(
return self.follow('demoting self because i do not have the lock and i was a leader', self.state_handler.name, lock_owner), refresh=False)
'no action. i am a secondary and i am following a leader', refresh=False) 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(
self.state_handler.name, lock_owner), refresh=False)
def evaluate_scheduled_restart(self): def evaluate_scheduled_restart(self):
if self._async_executor.busy: # Restart already in progress if self._async_executor.busy: # Restart already in progress
@@ -1227,9 +1219,6 @@ class Ha(object):
self._delete_leader() self._delete_leader()
return 'removed leader key after trying and failing to start postgres' return 'removed leader key after trying and failing to start postgres'
return 'failed to start postgres' return 'failed to start postgres'
self._crash_recovery_executed = False
if self._rewind.executed and not self._rewind.failed:
self._rewind.reset_state()
return None return None
def cancel_initialization(self): def cancel_initialization(self):
@@ -1261,7 +1250,6 @@ class Ha(object):
self.cancel_initialization() self.cancel_initialization()
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.state_handler.slots_handler.sync_replication_slots(self.cluster)
self.dcs.take_leader() self.dcs.take_leader()
self.set_is_leader(True) self.set_is_leader(True)
self.state_handler.call_nowait(ACTION_ON_START) self.state_handler.call_nowait(ACTION_ON_START)
@@ -1317,8 +1305,12 @@ class Ha(object):
def _run_cycle(self): def _run_cycle(self):
dcs_failed = False dcs_failed = False
try: try:
self.state_handler.reset_cluster_info_state() try:
self.load_cluster_from_dcs() self.load_cluster_from_dcs()
self.state_handler.reset_cluster_info_state(self.cluster, self.patroni.nofailover)
except Exception:
self.state_handler.reset_cluster_info_state(None, self.patroni.nofailover)
raise
if self.is_paused(): if self.is_paused():
self.watchdog.disable() self.watchdog.disable()
@@ -1350,12 +1342,24 @@ class Ha(object):
if self.state_handler.bootstrapping: if self.state_handler.bootstrapping:
return self.post_bootstrap() return self.post_bootstrap()
if self.recovering and not self._rewind.is_needed: if self.recovering:
self.recovering = False self.recovering = False
# Check if we tried to recover and failed
msg = self.post_recover() if not self._rewind.is_needed:
if msg is not None: # Check if we tried to recover from postgres crash and failed
return msg msg = self.post_recover()
if msg is not None:
return msg
# Reset some states after postgres successfully started up
self._crash_recovery_executed = False
if self._rewind.executed and not self._rewind.failed:
self._rewind.reset_state()
# The Raft cluster without a quorum takes a bit of time to stabilize.
# Therefore we want to postpone the leader race if we just started up.
if self.cluster.is_unlocked() and self.dcs.__class__.__name__ == 'Raft':
return 'started as a secondary'
# is data directory empty? # is data directory empty?
if self.state_handler.data_directory_empty(): if self.state_handler.data_directory_empty():
@@ -1424,20 +1428,28 @@ class Ha(object):
try: try:
if self.cluster.is_unlocked(): if self.cluster.is_unlocked():
return self.process_unhealthy_cluster() ret = self.process_unhealthy_cluster()
else: else:
msg = self.process_healthy_cluster() msg = self.process_healthy_cluster()
return self.evaluate_scheduled_restart() or msg ret = self.evaluate_scheduled_restart() or msg
finally: finally:
# we might not have a valid PostgreSQL connection here if another thread # we might not have a valid PostgreSQL connection here if another thread
# stops PostgreSQL, therefore, we only reload replication slots if no # stops PostgreSQL, therefore, we only reload replication slots if no
# asynchronous processes are running (should be always the case for the master) # asynchronous processes are running (should be always the case for the master)
if not self._async_executor.busy and not self.state_handler.is_starting(): if not self._async_executor.busy and not self.state_handler.is_starting():
self.state_handler.slots_handler.sync_replication_slots(self.cluster) create_slots = self.state_handler.slots_handler.sync_replication_slots(self.cluster,
self.patroni.nofailover)
if not self.state_handler.cb_called: if not self.state_handler.cb_called:
if not self.state_handler.is_leader(): if not self.state_handler.is_leader():
self._rewind.trigger_check_diverged_lsn() self._rewind.trigger_check_diverged_lsn()
self.state_handler.call_nowait(ACTION_ON_START) self.state_handler.call_nowait(ACTION_ON_START)
if create_slots and self.cluster.leader:
err = self._async_executor.try_run_async('copy_logical_slots',
self.state_handler.slots_handler.copy_logical_slots,
args=(self.cluster.leader, create_slots))
if not err:
ret = 'Copying logical slots {0} from the primary'.format(create_slots)
return ret
except DCSError: except DCSError:
dcs_failed = True dcs_failed = True
logger.error('Error communicating with DCS') logger.error('Error communicating with DCS')
@@ -1468,7 +1480,7 @@ class Ha(object):
self.watchdog.disable() self.watchdog.disable()
elif not self._join_aborted: elif not self._join_aborted:
# FIXME: If stop doesn't reach safepoint quickly enough keepalive is triggered. If shutdown checkpoint # FIXME: If stop doesn't reach safepoint quickly enough keepalive is triggered. If shutdown checkpoint
# takes longer than ttl, then leader key is lost and replication might not have sent out all xlog. # takes longer than ttl, then leader key is lost and replication might not have sent out all WAL.
# 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
+14 -1
View File
@@ -166,6 +166,8 @@ class PatroniLogger(Thread):
self._root_logger.addHandler(self._queue_handler) self._root_logger.addHandler(self._queue_handler)
self._root_logger.removeHandler(self._proxy_handler) self._root_logger.removeHandler(self._proxy_handler)
prev_record = None
while True: while True:
self._close_old_handlers() self._close_old_handlers()
@@ -173,7 +175,18 @@ class PatroniLogger(Thread):
if record is None: if record is None:
break break
self.log_handler.handle(record) if self._root_logger.level == logging.INFO:
if record.msg.startswith('Lock owner: '):
prev_record, record = record, None
else:
if prev_record and prev_record.thread == record.thread:
if not (record.msg.startswith('no action. ') or record.msg.startswith('PAUSE: no action')):
self.log_handler.handle(prev_record)
prev_record = None
if record:
self.log_handler.handle(record)
self._queue_handler.queue.task_done() self._queue_handler.queue.task_done()
def shutdown(self): def shutdown(self):
+121 -36
View File
@@ -1,28 +1,31 @@
import logging import logging
import os import os
import psycopg2 import psycopg2
import re
import shlex import shlex
import shutil import shutil
import six
import subprocess import subprocess
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from copy import deepcopy from copy import deepcopy
from dateutil import tz
from datetime import datetime from datetime import datetime
from patroni.postgresql.callback_executor import CallbackExecutor from dateutil import tz
from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.cancellable import CancellableSubprocess
from patroni.postgresql.config import ConfigHandler, mtime
from patroni.postgresql.connection import Connection, get_connection_cursor
from patroni.postgresql.misc import parse_history, parse_lsn, postgres_major_version_to_int
from patroni.postgresql.postmaster import PostmasterProcess
from patroni.postgresql.slots import SlotsHandler
from patroni.exceptions import PostgresConnectionException
from patroni.utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
from psutil import TimeoutExpired from psutil import TimeoutExpired
from threading import current_thread, Lock from threading import current_thread, Lock
from .bootstrap import Bootstrap
from .callback_executor import CallbackExecutor
from .cancellable import CancellableSubprocess
from .config import ConfigHandler, mtime
from .connection import Connection, get_connection_cursor
from .misc import parse_history, parse_lsn, postgres_major_version_to_int
from .postmaster import PostmasterProcess
from .slots import SlotsHandler
from ..exceptions import PostgresConnectionException
from ..utils import Retry, RetryFailedError, polling_loop, data_directory_is_empty, parse_int
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,13 +51,13 @@ def null_context():
class Postgresql(object): class Postgresql(object):
POSTMASTER_START_TIME = "pg_catalog.to_char(pg_catalog.pg_postmaster_start_time(), 'YYYY-MM-DD HH24:MI:SS.MS TZ')" POSTMASTER_START_TIME = "pg_catalog.pg_postmaster_start_time()"
TL_LSN = ("CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 " TL_LSN = ("CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
"ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name(" "ELSE ('x' || pg_catalog.substr(pg_catalog.pg_{0}file_name("
"pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, " # master timeline "pg_catalog.pg_current_{0}_{1}()), 1, 8))::bit(32)::int END, " # master timeline
"CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 " "CASE WHEN pg_catalog.pg_is_in_recovery() THEN 0 "
"ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint END, " # write_lsn "ELSE pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_current_{0}_{1}(), '0/0')::bigint END, " # write_lsn
"pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint, " "pg_catalog.pg_{0}_{1}_diff(pg_catalog.pg_last_{0}_replay_{1}(), '0/0')::bigint, "
"pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint, " "pg_catalog.pg_{0}_{1}_diff(COALESCE(pg_catalog.pg_last_{0}_receive_{1}(), '0/0'), '0/0')::bigint, "
"pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused()") "pg_catalog.pg_is_in_recovery() AND pg_catalog.pg_is_{0}_replay_paused()")
@@ -101,15 +104,19 @@ class Postgresql(object):
self._state_entry_timestamp = None self._state_entry_timestamp = None
self._cluster_info_state = {} self._cluster_info_state = {}
self._has_permanent_logical_slots = True
self._enforce_hot_standby_feedback = False
self._cached_replica_timeline = None self._cached_replica_timeline = None
# Last known running process # Last known running process
self._postmaster_proc = None self._postmaster_proc = None
if self.is_running(): if self.is_running(): # we are "joining" already running postgres
self.set_state('running') self.set_state('running')
self.set_role('master' if self.is_leader() else 'replica') self.set_role('master' if self.is_leader() else 'replica')
self.config.write_postgresql_conf() # we are "joining" already running postgres # postpone writing postgresql.conf for 12+ because recovery parameters are not yet known
if self.major_version < 120000 or self.is_leader():
self.config.write_postgresql_conf()
hba_saved = self.config.replace_pg_hba() hba_saved = self.config.replace_pg_hba()
ident_saved = self.config.replace_pg_ident() ident_saved = self.config.replace_pg_ident()
if hba_saved or ident_saved: if hba_saved or ident_saved:
@@ -152,14 +159,18 @@ class Postgresql(object):
@property @property
def cluster_info_query(self): def cluster_info_query(self):
if self._major_version >= 90600: if self._major_version >= 90600:
extra = "(SELECT pg_catalog.json_agg(s.*) FROM (SELECT slot_name, slot_type as type, datoid::bigint, " +\
"plugin, catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint" + \
" AS confirmed_flush_lsn FROM pg_catalog.pg_get_replication_slots()) AS s)"\
if self._has_permanent_logical_slots and self._major_version >= 110000 else "NULL"
extra = (", CASE WHEN latest_end_lsn IS NULL THEN NULL ELSE received_tli END," extra = (", CASE WHEN latest_end_lsn IS NULL THEN NULL ELSE received_tli END,"
" slot_name, conninfo FROM pg_catalog.pg_stat_get_wal_receiver()") " slot_name, conninfo, {0} FROM pg_catalog.pg_stat_get_wal_receiver()").format(extra)
if self.role == 'standby_leader': if self.role == 'standby_leader':
extra = "timeline_id" + extra + ", pg_catalog.pg_control_checkpoint()" extra = "timeline_id" + extra + ", pg_catalog.pg_control_checkpoint()"
else: else:
extra = "0" + extra extra = "0" + extra
else: else:
extra = "0, NULL, NULL, NULL" extra = "0, NULL, NULL, NULL, NULL"
return ("SELECT " + self.TL_LSN + ", {2}").format(self.wal_name, self.lsn_name, extra) return ("SELECT " + self.TL_LSN + ", {2}").format(self.wal_name, self.lsn_name, extra)
@@ -299,16 +310,36 @@ class Postgresql(object):
replica_methods = self.create_replica_methods replica_methods = self.create_replica_methods
return any(self.replica_method_can_work_without_replication_connection(m) for m in replica_methods) return any(self.replica_method_can_work_without_replication_connection(m) for m in replica_methods)
def reset_cluster_info_state(self): @property
def enforce_hot_standby_feedback(self):
return self._enforce_hot_standby_feedback
def set_enforce_hot_standby_feedback(self, value):
# If we enable or disable the hot_standby_feedback we need to update postgresql.conf and reload
if self._enforce_hot_standby_feedback != value:
self._enforce_hot_standby_feedback = value
if self.is_running():
self.config.write_postgresql_conf()
self.reload()
def reset_cluster_info_state(self, cluster, nofailover=None):
self._cluster_info_state = {} self._cluster_info_state = {}
if cluster and cluster.config and cluster.config.modify_index:
self._has_permanent_logical_slots =\
cluster.has_permanent_logical_slots(self.name, nofailover, self.major_version)
self.set_enforce_hot_standby_feedback(
self._has_permanent_logical_slots or
cluster.should_enforce_hot_standby_feedback(self.name, nofailover, self.major_version))
def _cluster_info_state_get(self, name): def _cluster_info_state_get(self, name):
if not self._cluster_info_state: if not self._cluster_info_state:
try: try:
result = self._is_leader_retry(self._query, self.cluster_info_query).fetchone() result = self._is_leader_retry(self._query, self.cluster_info_query).fetchone()
self._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'], result)) 'received_tli', 'slot_name', 'conninfo', 'slots'], result))
cluster_info_state['slots'] = self.slots_handler.process_permanent_slots(cluster_info_state['slots'])
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)}
if not self.is_starting() and self.pg_isready() == STATE_REJECT: if not self.is_starting() and self.pg_isready() == STATE_REJECT:
@@ -325,6 +356,9 @@ class Postgresql(object):
def received_location(self): def received_location(self):
return self._cluster_info_state_get('received_location') return self._cluster_info_state_get('received_location')
def slots(self):
return self._cluster_info_state_get('slots')
def primary_slot_name(self): def primary_slot_name(self):
return self._cluster_info_state_get('slot_name') return self._cluster_info_state_get('slot_name')
@@ -337,22 +371,58 @@ class Postgresql(object):
def is_leader(self): def is_leader(self):
return bool(self._cluster_info_state_get('timeline')) return bool(self._cluster_info_state_get('timeline'))
def replay_paused(self):
return self._cluster_info_state_get('replay_paused')
def resume_wal_replay(self):
self._query('SELECT pg_catalog.pg_{0}_replay_resume()'.format(self.wal_name))
def handle_parameter_change(self):
if self.major_version >= 140000 and self.replay_paused():
logger.info('Resuming paused WAL replay for PostgreSQL 14+')
self.resume_wal_replay()
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')
def parse_wal_record(self, timeline, lsn):
out, err = self.waldump(timeline, lsn, 1)
if out and not err:
match = re.match(r'^rmgr:\s+(.+?)\s+len \(rec/tot\):\s+\d+/\s+\d+, tx:\s+\d+, '
r'lsn: ([0-9A-Fa-f]+/[0-9A-Fa-f]+), prev ([0-9A-Fa-f]+/[0-9A-Fa-f]+), '
r'.*?desc: (.+)', out.decode('utf-8'))
if match:
return match.groups()
return None, None, None, None
def latest_checkpoint_location(self): def latest_checkpoint_location(self):
"""Returns checkpoint location for the cleanly shut down primary""" """Returns checkpoint location for the cleanly shut down primary.
But, if we know that the checkpoint was written to the new WAL
due to the archive_mode=on, we will return the LSN of prev wal record (SWITCH)."""
data = self.controldata() data = self.controldata()
lsn = data.get('Latest checkpoint location') timeline = data.get("Latest checkpoint's TimeLineID")
if data.get('Database cluster state') == 'shut down' and lsn: lsn = checkpoint_lsn = data.get('Latest checkpoint location')
if data.get('Database cluster state') == 'shut down' and lsn and timeline:
try: try:
return str(parse_lsn(lsn)) checkpoint_lsn = parse_lsn(checkpoint_lsn)
except (IndexError, ValueError) as e: rm_name, lsn, prev, desc = self.parse_wal_record(timeline, lsn)
logger.error('Exception when parsing lsn %s: %r', lsn, e) desc = desc.strip().lower()
if rm_name == 'XLOG' and parse_lsn(lsn) == checkpoint_lsn and prev and\
desc.startswith('checkpoint') and desc.endswith('shutdown'):
_, lsn, _, desc = self.parse_wal_record(timeline, prev)
prev = parse_lsn(prev)
# If the cluster is shutdown with archive_mode=on, WAL is switched before writing the checkpoint.
# In this case we want to take the LSN of previous record (switch) as the last known WAL location.
if parse_lsn(lsn) == prev and desc.strip() in ('xlog switch', 'SWITCH'):
return str(prev)
except Exception as e:
logger.error('Exception when parsing WAL pg_%sdump output: %r', self.wal_name, e)
if isinstance(checkpoint_lsn, six.integer_types):
return str(checkpoint_lsn)
def is_running(self): def is_running(self):
"""Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process """Returns PostmasterProcess if one is running on the data directory or None. If most recently seen process
@@ -362,7 +432,7 @@ class Postgresql(object):
return self._postmaster_proc return self._postmaster_proc
self._postmaster_proc = None self._postmaster_proc = None
# we noticed that postgres was restarted, force syncing of replication # we noticed that postgres was restarted, force syncing of replication slots and check of logical slots
self.slots_handler.schedule() self.slots_handler.schedule()
self._postmaster_proc = PostmasterProcess.from_pidfile(self._data_dir) self._postmaster_proc = PostmasterProcess.from_pidfile(self._data_dir)
@@ -724,6 +794,20 @@ class Postgresql(object):
logger.exception("Error when calling pg_controldata") logger.exception("Error when calling pg_controldata")
return {} return {}
def waldump(self, timeline, lsn, limit):
cmd = self.pgcommand('pg_{0}dump'.format(self.wal_name))
env = os.environ.copy()
env.update(LANG='C', LC_ALL='C', PGDATA=self._data_dir)
try:
waldump = subprocess.Popen([cmd, '-t', str(timeline), '-s', str(lsn), '-n', str(limit)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
out, err = waldump.communicate()
waldump.wait()
return out, err
except Exception as e:
logger.error('Failed to execute `%s -t %s -s %s -n %s`: %r', cmd, timeline, lsn, limit, e)
return None, None
@contextmanager @contextmanager
def get_replication_connection_cursor(self, host='localhost', port=5432, **kwargs): def get_replication_connection_cursor(self, host='localhost', port=5432, **kwargs):
conn_kwargs = self.config.replication.copy() conn_kwargs = self.config.replication.copy()
@@ -812,7 +896,7 @@ class Postgresql(object):
logger.info('pre_promote script `%s` exited with %s', cmd, ret) logger.info('pre_promote script `%s` exited with %s', cmd, ret)
return ret == 0 return ret == 0
def promote(self, wait_seconds, task, on_success=None, access_is_restricted=False): def promote(self, wait_seconds, task, on_success=None):
if self.role == 'master': if self.role == 'master':
return True return True
@@ -829,13 +913,14 @@ class Postgresql(object):
logger.info("PostgreSQL promote cancelled.") logger.info("PostgreSQL promote cancelled.")
return False return False
self.slots_handler.on_promote()
ret = self.pg_ctl('promote', '-W') ret = self.pg_ctl('promote', '-W')
if ret: if ret:
self.set_role('master') self.set_role('master')
if on_success is not None: if on_success is not None:
on_success() on_success()
if not access_is_restricted: self.call_nowait(ACTION_ON_ROLE_CHANGE)
self.call_nowait(ACTION_ON_ROLE_CHANGE)
ret = self._wait_promote(wait_seconds) ret = self._wait_promote(wait_seconds)
return ret return ret
@@ -865,16 +950,16 @@ class Postgresql(object):
try: try:
query = "SELECT " + self.POSTMASTER_START_TIME query = "SELECT " + self.POSTMASTER_START_TIME
if current_thread().ident == self.__thread_ident: if current_thread().ident == self.__thread_ident:
return self.query(query).fetchone()[0] return self.query(query).fetchone()[0].isoformat(sep=' ')
with self.connection().cursor() as cursor: with self.connection().cursor() as cursor:
cursor.execute(query) cursor.execute(query)
return cursor.fetchone()[0] return cursor.fetchone()[0].isoformat(sep=' ')
except psycopg2.Error: except psycopg2.Error:
return None return None
def last_operation(self): def last_operation(self):
return str(self._wal_position(self.is_leader(), self._cluster_info_state_get('wal_position'), return self._wal_position(self.is_leader(), self._cluster_info_state_get('wal_position'),
self.received_location(), self.replayed_location())) self.received_location(), self.replayed_location())
def configure_server_parameters(self): def configure_server_parameters(self):
self._major_version = self.get_major_version() self._major_version = self.get_major_version()
@@ -967,7 +1052,7 @@ class Postgresql(object):
Current synchronous standby is always preferred, unless it has disconnected or does not want to be a Current synchronous standby is always preferred, unless it has disconnected or does not want to be a
synchronous standby any longer. synchronous standby any longer.
Parameter sync_node_maxlag(maximum_lag_on_syncnode) would help swapping unhealthy sync replica incase Parameter sync_node_maxlag(maximum_lag_on_syncnode) would help swapping unhealthy sync replica in case
if it stops responding (or hung). Please set the value high enough so it won't unncessarily swap sync if it stops responding (or hung). Please set the value high enough so it won't unncessarily swap sync
standbys during high loads. Any less or equal of 0 value keep the behavior backward compatible and standbys during high loads. Any less or equal of 0 value keep the behavior backward compatible and
will not swap. Please note that it will not also swap sync standbys in case where all replicas are hung. will not swap. Please note that it will not also swap sync standbys in case where all replicas are hung.
+3 -3
View File
@@ -53,7 +53,7 @@ class Bootstrap(object):
error_handler('Error when parsing {0} option {1}: value should be string value' error_handler('Error when parsing {0} option {1}: value should be string value'
' or a single key-value pair'.format(tool, opt)) ' or a single key-value pair'.format(tool, opt))
else: else:
error_handler('{0} options must be list ot dict'.format(tool)) error_handler('{0} options must be list or dict'.format(tool))
return user_options return user_options
def _initdb(self, config): def _initdb(self, config):
@@ -90,8 +90,8 @@ class Bootstrap(object):
self._postgresql.configure_server_parameters() self._postgresql.configure_server_parameters()
# make sure there is no trigger file or postgres will be automatically promoted # make sure there is no trigger file or postgres will be automatically promoted
trigger_file = 'promote_trigger_file' if self._postgresql.major_version >= 120000 else 'trigger_file' trigger_file = self._postgresql.config.triggerfile_good_name
trigger_file = self._postgresql.config.get('recovery_conf', {}).get(trigger_file) or 'promote' trigger_file = (self._postgresql.config.get('recovery_conf') or {}).get(trigger_file) or 'promote'
trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file)) trigger_file = os.path.abspath(os.path.join(self._postgresql.data_dir, trigger_file))
if os.path.exists(trigger_file): if os.path.exists(trigger_file):
os.unlink(trigger_file) os.unlink(trigger_file)
+1 -1
View File
@@ -36,7 +36,7 @@ class CancellableExecutor(object):
with self._lock: with self._lock:
if self._process is not None and self._process.is_running() and not self._process_children: if self._process is not None and self._process.is_running() and not self._process_children:
try: try:
self._process.suspend() # Suspend the process before getting list of childrens self._process.suspend() # Suspend the process before getting list of children
except psutil.Error as e: except psutil.Error as e:
logger.info('Failed to suspend the process: %s', e.msg) logger.info('Failed to suspend the process: %s', e.msg)
+40 -33
View File
@@ -336,8 +336,9 @@ class ConfigHandler(object):
if "stats_temp_directory" in self._server_parameters: if "stats_temp_directory" in self._server_parameters:
self.try_to_create_dir(self._server_parameters["stats_temp_directory"], self.try_to_create_dir(self._server_parameters["stats_temp_directory"],
"'{}' is defined in stats_temp_directory, {}") "'{}' is defined in stats_temp_directory, {}")
self.try_to_create_dir(os.path.dirname(self._pgpass), if not self._krbsrvname:
"'{}' is defined in `postgresql.pgpass`, {}") self.try_to_create_dir(os.path.dirname(self._pgpass),
"'{}' is defined in `postgresql.pgpass`, {}")
@property @property
def _configuration_to_save(self): def _configuration_to_save(self):
@@ -352,7 +353,7 @@ class ConfigHandler(object):
def save_configuration_files(self, check_custom_bootstrap=False): def save_configuration_files(self, check_custom_bootstrap=False):
""" """
copy postgresql.conf to postgresql.conf.backup to be able to retrive configuration files copy postgresql.conf to postgresql.conf.backup to be able to retrieve configuration files
- originally stored as symlinks, those are normally skipped by pg_basebackup - originally stored as symlinks, those are normally skipped by pg_basebackup
- in case of WAL-E basebackup (see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239) - in case of WAL-E basebackup (see http://comments.gmane.org/gmane.comp.db.postgresql.wal-e/239)
""" """
@@ -387,22 +388,22 @@ class ConfigHandler(object):
if 'custom_conf' not in self._config and not os.path.exists(self._postgresql_base_conf): if 'custom_conf' not in self._config and not os.path.exists(self._postgresql_base_conf):
os.rename(self._postgresql_conf, self._postgresql_base_conf) os.rename(self._postgresql_conf, self._postgresql_base_conf)
# In case we are using custom bootstrap from spilo image with PITR it fails if it contains increasing configuration = configuration or self._server_parameters.copy()
# values like Max_connections. We disable hot_standby so it will accept increasing values. # Due to the permanent logical replication slots configured we have to enable hot_standby_feedback
if self._postgresql.bootstrap.running_custom_bootstrap: if self._postgresql.enforce_hot_standby_feedback:
configuration['hot_standby'] = 'off' configuration['hot_standby_feedback'] = 'on'
with ConfigWriter(self._postgresql_conf) as f: with ConfigWriter(self._postgresql_conf) as f:
include = self._config.get('custom_conf') or self._postgresql_base_conf_name include = self._config.get('custom_conf') or self._postgresql_base_conf_name
f.writeline("include '{0}'\n".format(ConfigWriter.escape(include))) f.writeline("include '{0}'\n".format(ConfigWriter.escape(include)))
for name, value in sorted((configuration or self._server_parameters).items()): for name, value in sorted((configuration).items()):
value = transform_postgresql_parameter_value(self._postgresql.major_version, name, value) value = transform_postgresql_parameter_value(self._postgresql.major_version, name, value)
if (not self._postgresql.bootstrap.running_custom_bootstrap or name != 'hba_file') \ if value is not None and\
and name not in self._RECOVERY_PARAMETERS and value is not None: (name != 'hba_file' or not self._postgresql.bootstrap.running_custom_bootstrap):
f.write_param(name, value) f.write_param(name, value)
# when we are doing custom bootstrap we assume that we don't know superuser password # when we are doing custom bootstrap we assume that we don't know superuser password
# and in order to be able to change it, we are opening trust access from a certain address # and in order to be able to change it, we are opening trust access from a certain address
# therefore we need to make sure that hba_file is not overriden # therefore we need to make sure that hba_file is not overridden
# after changing superuser password we will "revert" all these "changes" # after changing superuser password we will "revert" all these "changes"
if self._postgresql.bootstrap.running_custom_bootstrap or 'hba_file' not in self._server_parameters: if self._postgresql.bootstrap.running_custom_bootstrap or 'hba_file' not in self._server_parameters:
f.write_param('hba_file', self._pg_hba_conf) f.write_param('hba_file', self._pg_hba_conf)
@@ -553,7 +554,7 @@ class ConfigHandler(object):
return os.path.exists(self._recovery_conf) return os.path.exists(self._recovery_conf)
@property @property
def _triggerfile_good_name(self): def triggerfile_good_name(self):
return 'trigger_file' if self._postgresql.major_version < 120000 else 'promote_trigger_file' return 'trigger_file' if self._postgresql.major_version < 120000 else 'promote_trigger_file'
@property @property
@@ -701,7 +702,9 @@ class ConfigHandler(object):
if wal_receiver_primary_slot_name is not None: if wal_receiver_primary_slot_name is not None:
self._current_recovery_params['primary_slot_name'][0] = wal_receiver_primary_slot_name self._current_recovery_params['primary_slot_name'][0] = wal_receiver_primary_slot_name
required = {'restart': 0, 'reload': 0} # Increment the 'reload' to enforce write of postgresql.conf when joining the running postgres
required = {'restart': 0,
'reload': int(not self._postgresql.cb_called and self._postgresql.major_version >= 120000)}
def record_missmatch(mtype): def record_missmatch(mtype):
required['restart' if mtype else 'reload'] += 1 required['restart' if mtype else 'reload'] += 1
@@ -756,13 +759,13 @@ class ConfigHandler(object):
return env return env
def write_recovery_conf(self, recovery_params): def write_recovery_conf(self, recovery_params):
self._recovery_params = recovery_params
if self._postgresql.major_version >= 120000: if self._postgresql.major_version >= 120000:
if parse_bool(recovery_params.pop('standby_mode', None)): if parse_bool(recovery_params.pop('standby_mode', None)):
open(self._standby_signal, 'w').close() open(self._standby_signal, 'w').close()
else: else:
self._remove_file_if_exists(self._standby_signal) self._remove_file_if_exists(self._standby_signal)
open(self._recovery_signal, 'w').close() open(self._recovery_signal, 'w').close()
self._recovery_params = recovery_params
else: else:
with ConfigWriter(self._recovery_conf) as f: with ConfigWriter(self._recovery_conf) as f:
os.chmod(self._recovery_conf, stat.S_IWRITE | stat.S_IREAD) os.chmod(self._recovery_conf, stat.S_IWRITE | stat.S_IREAD)
@@ -806,8 +809,8 @@ class ConfigHandler(object):
if self.get('recovery_conf'): if self.get('recovery_conf'):
value = self._config['recovery_conf'].pop(self._triggerfile_wrong_name, None) value = self._config['recovery_conf'].pop(self._triggerfile_wrong_name, None)
if self._triggerfile_good_name not in self._config['recovery_conf'] and value: if self.triggerfile_good_name not in self._config['recovery_conf'] and value:
self._config['recovery_conf'][self._triggerfile_good_name] = value self._config['recovery_conf'][self.triggerfile_good_name] = value
def get_server_parameters(self, config): def get_server_parameters(self, config):
parameters = config['parameters'].copy() parameters = config['parameters'].copy()
@@ -876,25 +879,21 @@ class ConfigHandler(object):
def resolve_connection_addresses(self): def resolve_connection_addresses(self):
port = self._server_parameters['port'] port = self._server_parameters['port']
tcp_local_address = self._get_tcp_local_address() tcp_local_address = self._get_tcp_local_address()
local_address = {'port': port}
if self._config.get('use_unix_socket'):
unix_socket_directories = self._server_parameters.get('unix_socket_directories')
if unix_socket_directories is not None:
# fallback to tcp if unix_socket_directories is set, but there are no sutable values
local_address['host'] = self._get_unix_local_address(unix_socket_directories) or tcp_local_address
# if unix_socket_directories is not specified, but use_unix_socket is set to true - do our best
# to use default value, i.e. don't specify a host neither in connection url nor arguments
else:
local_address['host'] = tcp_local_address
self._local_address = local_address
self.local_replication_address = {'host': tcp_local_address, 'port': port}
netloc = self._config.get('connect_address') or tcp_local_address + ':' + port netloc = self._config.get('connect_address') or tcp_local_address + ':' + port
self._postgresql.connection_string = uri('postgres', netloc, self._postgresql.database)
unix_local_address = {'port': port}
unix_socket_directories = self._server_parameters.get('unix_socket_directories')
if unix_socket_directories is not None:
# fallback to tcp if unix_socket_directories is set, but there are no suitable values
unix_local_address['host'] = self._get_unix_local_address(unix_socket_directories) or tcp_local_address
tcp_local_address = {'host': tcp_local_address, 'port': port}
self._local_address = unix_local_address if self._config.get('use_unix_socket') else tcp_local_address
self.local_replication_address = unix_local_address\
if self._config.get('use_unix_socket_repl') else tcp_local_address
self._postgresql.connection_string = uri('postgres', netloc, self._postgresql.database)
self._postgresql.set_connection_kwargs(self.local_connect_kwargs) self._postgresql.set_connection_kwargs(self.local_connect_kwargs)
def _get_pg_settings(self, names): def _get_pg_settings(self, names):
@@ -1067,6 +1066,14 @@ class ConfigHandler(object):
if cvalue > value: if cvalue > value:
effective_configuration[name] = cvalue effective_configuration[name] = cvalue
self._postgresql.set_pending_restart(True) self._postgresql.set_pending_restart(True)
# If we are using custom bootstrap with PITR it could fail when values
# like max_connections are increased, therefore we disable hot_standby.
if self._postgresql.bootstrap.running_custom_bootstrap and \
(self._postgresql.bootstrap.keep_existing_recovery_conf or self._recovery_conf):
effective_configuration['hot_standby'] = 'off'
self._postgresql.set_pending_restart(True)
return effective_configuration return effective_configuration
@property @property
+5 -4
View File
@@ -40,7 +40,8 @@ class Connection(object):
@contextmanager @contextmanager
def get_connection_cursor(**kwargs): def get_connection_cursor(**kwargs):
with psycopg2.connect(**kwargs) as conn: conn = psycopg2.connect(**kwargs)
conn.autocommit = True conn.autocommit = True
with conn.cursor() as cur: with conn.cursor() as cur:
yield cur yield cur
conn.close()
+6 -1
View File
@@ -37,7 +37,7 @@ def postgres_version_to_int(pg_version):
raise PostgresException('Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: {0}'.format(pg_version)) raise PostgresException('Invalid PostgreSQL version format: X.Y or X.Y.Z is accepted: {0}'.format(pg_version))
if len(components) == 2: if len(components) == 2:
# new style verion numbers, i.e. 10.1 becomes 100001 # new style version numbers, i.e. 10.1 becomes 100001
components.insert(1, 0) components.insert(1, 0)
return int(''.join('{0:02d}'.format(c) for c in components)) return int(''.join('{0:02d}'.format(c) for c in components))
@@ -68,3 +68,8 @@ def parse_history(data):
yield values yield values
except (IndexError, ValueError): except (IndexError, ValueError):
logger.exception('Exception when parsing timeline history line "%s"', values) logger.exception('Exception when parsing timeline history line "%s"', values)
def format_lsn(lsn, full=False):
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
+8 -20
View File
@@ -7,7 +7,7 @@ import subprocess
from threading import Lock, Thread from threading import Lock, Thread
from .connection import get_connection_cursor from .connection import get_connection_cursor
from .misc import parse_history, parse_lsn from .misc import format_lsn, parse_history, parse_lsn
from ..async_executor import CriticalTask from ..async_executor import CriticalTask
from ..dcs import Leader from ..dcs import Leader
@@ -17,11 +17,6 @@ REWIND_STATUS = type('Enum', (), {'INITIAL': 0, 'CHECKPOINT': 1, 'CHECK': 2, 'NE
'NOT_NEED': 4, 'SUCCESS': 5, 'FAILED': 6}) 'NOT_NEED': 4, 'SUCCESS': 5, 'FAILED': 6})
def format_lsn(lsn, full=False):
template = '{0:X}/{1:08X}' if full else '{0:X}/{1:X}'
return template.format(lsn >> 32, lsn & 0xFFFFFFFF)
class Rewind(object): class Rewind(object):
def __init__(self, postgresql): def __init__(self, postgresql):
@@ -73,23 +68,14 @@ class Rewind(object):
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
and parse the output. We are trying to read two records, and expect that it wil fail to read the second one: and parse the output. We are trying to read two records, and expect that it will fail to read the second one:
`pg_waldump: fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0` `pg_waldump: fatal: error in WAL record at 0/182E220: invalid record length at 0/182E298: wanted 24, got 0`
The error message contains information about LSN of the next record, which is exactly where checkpoint ends.""" The error message contains information about LSN of the next record, which is exactly where checkpoint ends."""
cmd = self._postgresql.pgcommand('pg_{0}dump'.format(self._postgresql.wal_name))
lsn8 = format_lsn(lsn, True) lsn8 = format_lsn(lsn, True)
lsn = format_lsn(lsn) lsn = format_lsn(lsn)
env = os.environ.copy() out, err = self._postgresql.waldump(timeline, lsn, 2)
env.update(LANG='C', LC_ALL='C', PGDATA=self._postgresql.data_dir) if out is not None and err is not None:
try:
waldump = subprocess.Popen([cmd, '-t', str(timeline), '-s', lsn, '-n', '2'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
out, err = waldump.communicate()
waldump.wait()
except Exception as e:
logger.error('Failed to execute `%s -t %s -s %s -n 2`: %r', cmd, timeline, lsn, e)
else:
out = out.decode('utf-8').rstrip().split('\n') out = out.decode('utf-8').rstrip().split('\n')
err = err.decode('utf-8').rstrip().split('\n') err = err.decode('utf-8').rstrip().split('\n')
pattern = 'error in WAL record at {0}: invalid record length at '.format(lsn) pattern = 'error in WAL record at {0}: invalid record length at '.format(lsn)
@@ -102,7 +88,7 @@ class Rewind(object):
return parse_lsn(err[0][i:j]) return parse_lsn(err[0][i:j])
except Exception as e: except Exception as e:
logger.error('Failed to parse lsn %s: %r', err[0][i:j], e) logger.error('Failed to parse lsn %s: %r', err[0][i:j], e)
logger.error('Failed to parse `%s -t %s -s %s -n 2` output', cmd, timeline, lsn) logger.error('Failed to parse pg_%sdump output', self._postgresql.wal_name)
logger.error(' stdout=%s', '\n'.join(out)) logger.error(' stdout=%s', '\n'.join(out))
logger.error(' stderr=%s', '\n'.join(err)) logger.error(' stderr=%s', '\n'.join(err))
@@ -194,7 +180,9 @@ class Rewind(object):
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 %s', (master_timeline,))
history = bytes(cur.fetchone()[1]).decode('utf-8') history = cur.fetchone()[1]
if not isinstance(history, six.string_types):
history = bytes(history).decode('utf-8')
logger.debug('master: history=%s', history) logger.debug('master: history=%s', history)
except Exception: except Exception:
return logger.exception('Exception when working with master via replication connection') return logger.exception('Exception when working with master via replication connection')
+242 -51
View File
@@ -1,14 +1,34 @@
import errno
import logging import logging
import os
import shutil
from patroni.postgresql.connection import get_connection_cursor
from collections import defaultdict from collections import defaultdict
from contextlib import contextmanager
from psycopg2.errors import UndefinedFile
from .connection import get_connection_cursor
from .misc import format_lsn
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def compare_slots(s1, s2): def compare_slots(s1, s2, dbid='database'):
return s1['type'] == s2['type'] and (s1['type'] == 'physical' or return s1['type'] == s2['type'] and (s1['type'] == 'physical' or
s1['database'] == s2['database'] and s1['plugin'] == s2['plugin']) s1.get(dbid) == s2.get(dbid) and s1['plugin'] == s2['plugin'])
def fsync_dir(path):
if os.name != 'nt':
fd = os.open(path, os.O_DIRECTORY)
try:
os.fsync(fd)
except OSError as e:
# Some filesystems don't like fsyncing directories and raise EINVAL. Ignoring it is usually safe.
if e.errno != errno.EINVAL:
raise
finally:
os.close(fd)
class SlotsHandler(object): class SlotsHandler(object):
@@ -16,22 +36,66 @@ class SlotsHandler(object):
def __init__(self, postgresql): def __init__(self, postgresql):
self._postgresql = postgresql self._postgresql = postgresql
self._replication_slots = {} # already existing replication slots self._replication_slots = {} # already existing replication slots
self._unready_logical_slots = set()
self.schedule() self.schedule()
def _query(self, sql, *params): def _query(self, sql, *params):
return self._postgresql.query(sql, *params, retry=False) return self._postgresql.query(sql, *params, retry=False)
@staticmethod
def _copy_items(src, dst, keys=None):
dst.update({key: src[key] for key in keys or ('datoid', 'catalog_xmin', 'confirmed_flush_lsn')})
def process_permanent_slots(self, slots):
"""This methods solves three problems at once (I know, it is weird).
The cluster_info_query from `Postgresql` is executed every HA loop and returns
information about all replication slots that exists on the current host.
Based on this information we perform the following actions:
1. For the primary we want to expose to DCS permanent logical slots, therefore the method
builds (and returns) a dict, that maps permanent logical slot names and confirmed_flush_lsns.
2. This method also detects if one of the previously known permanent slots got missing and schedules resync.
3. Updates the local cache with the fresh catalog_xmin and confirmed_flush_lsn for every known slot.
This info is used when performing the check of logical slot readiness on standbys.
"""
ret = {}
slots = {slot['slot_name']: slot for slot in slots or []}
if slots:
for name, value in slots.items():
if name in self._replication_slots:
if compare_slots(value, self._replication_slots[name], 'datoid'):
if value['type'] == 'logical':
ret[name] = value['confirmed_flush_lsn']
self._copy_items(value, self._replication_slots[name])
else:
self._schedule_load_slots = True
# It could happen that the slots was deleted in the background, we want to detect this case
if any(name not in slots for name in self._replication_slots.keys()):
self._schedule_load_slots = True
return ret
def load_replication_slots(self): def load_replication_slots(self):
if self._postgresql.major_version >= 90400 and self._schedule_load_slots: if self._postgresql.major_version >= 90400 and self._schedule_load_slots:
replication_slots = {} replication_slots = {}
cursor = self._query('SELECT slot_name, slot_type, plugin, database FROM pg_catalog.pg_replication_slots') extra = ", catalog_xmin, pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint"\
if self._postgresql.major_version >= 100000 else ""
cursor = self._query('SELECT slot_name, slot_type, plugin, database, datoid'
'{0} FROM pg_catalog.pg_replication_slots'.format(extra))
for r in cursor: for r in cursor:
value = {'type': r[1]} value = {'type': r[1]}
if r[1] == 'logical': if r[1] == 'logical':
value.update({'plugin': r[2], 'database': r[3]}) value.update(plugin=r[2], database=r[3], datoid=r[4])
if self._postgresql.major_version >= 100000:
value.update(catalog_xmin=r[5], confirmed_flush_lsn=r[6])
replication_slots[r[0]] = value replication_slots[r[0]] = value
self._replication_slots = replication_slots self._replication_slots = replication_slots
self._schedule_load_slots = False self._schedule_load_slots = False
if self._force_readiness_check:
self._unready_logical_slots = set(n for n, v in replication_slots.items() if v['type'] == 'logical')
self._force_readiness_check = False
def ignore_replication_slot(self, cluster, name): def ignore_replication_slot(self, cluster, name):
slot = self._replication_slots[name] slot = self._replication_slots[name]
@@ -47,65 +111,192 @@ class SlotsHandler(object):
# In normal situation rowcount should be 1, otherwise either slot doesn't exists or it is still active # In normal situation rowcount should be 1, otherwise either slot doesn't exists or it is still active
return cursor.rowcount == 1 return cursor.rowcount == 1
def sync_replication_slots(self, cluster): def _drop_incorrect_slots(self, cluster, slots):
# drop old replication slots which are not presented in desired slots
for name in set(self._replication_slots) - set(slots):
if not self.ignore_replication_slot(cluster, name) and not self.drop_replication_slot(name):
logger.error("Failed to drop replication slot '%s'", name)
self._schedule_load_slots = True
for name, value in slots.items():
if name in self._replication_slots and not compare_slots(value, self._replication_slots[name]):
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s",
name, self._replication_slots[name], value)
if self.drop_replication_slot(name):
self._replication_slots.pop(name)
else:
logger.error("Failed to drop replication slot '%s'", name)
self._schedule_load_slots = True
def _ensure_physical_slots(self, slots):
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else ''
for name, value in slots.items():
if name not in self._replication_slots and value['type'] == 'physical':
try:
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
immediately_reserve), name, name)
except Exception:
logger.exception("Failed to create physical replication slot '%s'", name)
self._schedule_load_slots = True
@contextmanager
def _get_local_connection_cursor(self, database):
conn_kwargs = self._postgresql.config.local_connect_kwargs
conn_kwargs['database'] = database
with get_connection_cursor(**conn_kwargs) as cur:
yield cur
def _ensure_logical_slots_primary(self, slots):
# Group logical slots to be created by database name
logical_slots = defaultdict(dict)
for name, value in slots.items():
if value['type'] == 'logical':
# If the logical already exists, copy some information about it into the original structure
if self._replication_slots.get(name, {}).get('datoid'):
self._copy_items(self._replication_slots[name], value)
else:
logical_slots[value['database']][name] = value
# Create new logical slots
for database, values in logical_slots.items():
with self._get_local_connection_cursor(database) as cur:
for name, value in values.items():
try:
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
" WHERE slot_type = 'logical' AND slot_name = %s)",
(name, value['plugin'], name))
except Exception as e:
logger.error("Failed to create logical replication slot '%s' plugin='%s': %r",
name, value['plugin'], e)
slots.pop(name)
self._schedule_load_slots = True
def _ensure_logical_slots_replica(self, cluster, slots):
advance_slots = defaultdict(dict) # Group logical slots to be advanced by database name
create_slots = [] # And collect logical slots to be created on the replica
for name, value in slots.items():
if value['type'] == 'logical':
# If the logical already exists, copy some information about it into the original structure
if self._replication_slots.get(name, {}).get('datoid'):
self._copy_items(self._replication_slots[name], value)
if name in cluster.slots:
try: # Skip slots that doesn't need to be advanced
if value['confirmed_flush_lsn'] < int(cluster.slots[name]):
advance_slots[value['database']][name] = value
except Exception as e:
logger.error('Failed to parse "%s": %r', cluster.slots[name], e)
elif name in cluster.slots: # We want to copy only slots with feedback in a DCS
create_slots.append(name)
# Advance logical slots
for database, values in advance_slots.items():
with self._get_local_connection_cursor(database) as cur:
for name, value in values.items():
try:
cur.execute("SELECT pg_catalog.pg_replication_slot_advance(%s, %s)",
(name, format_lsn(int(cluster.slots[name]))))
except Exception as e:
logger.error("Failed to advance logical replication slot '%s': %r", name, e)
if isinstance(e, UndefinedFile):
create_slots.append(name)
self._schedule_load_slots = True
return create_slots
def sync_replication_slots(self, cluster, nofailover, replicatefrom=None):
ret = None
if self._postgresql.major_version >= 90400 and cluster.config: if self._postgresql.major_version >= 90400 and cluster.config:
try: try:
self.load_replication_slots() self.load_replication_slots()
slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role) slots = cluster.get_replication_slots(self._postgresql.name, self._postgresql.role,
nofailover, self._postgresql.major_version, True)
# drop old replication slots which are not presented in desired slots self._drop_incorrect_slots(cluster, slots)
for name in set(self._replication_slots) - set(slots):
if not self.ignore_replication_slot(cluster, name) and not self.drop_replication_slot(name):
logger.error("Failed to drop replication slot '%s'", name)
self._schedule_load_slots = True
immediately_reserve = ', true' if self._postgresql.major_version >= 90600 else '' self._ensure_physical_slots(slots)
logical_slots = defaultdict(dict) if self._postgresql.is_leader():
for name, value in slots.items(): self._unready_logical_slots.clear()
if name in self._replication_slots and not compare_slots(value, self._replication_slots[name]): self._ensure_logical_slots_primary(slots)
logger.info("Trying to drop replication slot '%s' because value is changing from %s to %s", elif cluster.slots and slots:
name, self._replication_slots[name], value) self.check_logical_slots_readiness(cluster, nofailover, replicatefrom)
if not self.drop_replication_slot(name):
logger.error("Failed to drop replication slot '%s'", name) ret = self._ensure_logical_slots_replica(cluster, slots)
self._schedule_load_slots = True
continue
self._replication_slots.pop(name)
if name not in self._replication_slots:
if value['type'] == 'physical':
try:
self._query(("SELECT pg_catalog.pg_create_physical_replication_slot(%s{0})" +
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
" WHERE slot_type = 'physical' AND slot_name = %s)").format(
immediately_reserve), name, name)
except Exception:
logger.exception("Failed to create physical replication slot '%s'", name)
self._schedule_load_slots = True
elif value['type'] == 'logical' and name not in self._replication_slots:
logical_slots[value['database']][name] = value
# create new logical slots
for database, values in logical_slots.items():
conn_kwargs = self._postgresql.config.local_connect_kwargs
conn_kwargs['database'] = database
with get_connection_cursor(**conn_kwargs) as cur:
for name, value in values.items():
try:
cur.execute("SELECT pg_catalog.pg_create_logical_replication_slot(%s, %s)" +
" WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_replication_slots" +
" WHERE slot_type = 'logical' AND slot_name = %s)",
(name, value['plugin'], name))
except Exception:
logger.exception("Failed to create logical replication slot '%s' plugin='%s'",
name, value['plugin'])
self._schedule_load_slots = True
self._replication_slots = slots self._replication_slots = slots
except Exception: except Exception:
logger.exception('Exception when changing replication slots') logger.exception('Exception when changing replication slots')
self._schedule_load_slots = True self._schedule_load_slots = True
return ret
@contextmanager
def _get_leader_connection_cursor(self, leader):
conn_kwargs = leader.conn_kwargs(self._postgresql.config.rewind_credentials)
conn_kwargs['database'] = self._postgresql.database
with get_connection_cursor(connect_timeout=3, options="-c statement_timeout=2000", **conn_kwargs) as cur:
yield cur
def check_logical_slots_readiness(self, cluster, nofailover, replicatefrom):
if self._unready_logical_slots:
slot_name = cluster.get_my_slot_name_on_primary(self._postgresql.name, replicatefrom)
try:
with self._get_leader_connection_cursor(cluster.leader) as cur:
cur.execute("SELECT catalog_xmin FROM pg_catalog.pg_get_replication_slots()"
" WHERE NOT pg_catalog.pg_is_in_recovery() AND slot_name = %s", (slot_name,))
if cur.rowcount < 1:
return logger.warning('Physical slot %s does not exist on the primary', slot_name)
catalog_xmin = cur.fetchone()[0]
except Exception as e:
return logger.error("Failed to check %s physical slot on the primary: %r", slot_name, e)
for name in list(self._unready_logical_slots):
value = self._replication_slots.get(name)
if not value or catalog_xmin <= value['catalog_xmin']:
self._unready_logical_slots.remove(name)
if value:
logger.info('Logical slot %s is safe to be used after a failover', name)
def copy_logical_slots(self, leader, slots):
with self._get_leader_connection_cursor(leader) as cur:
try:
cur.execute("SELECT slot_name, catalog_xmin, "
"pg_catalog.pg_wal_lsn_diff(confirmed_flush_lsn, '0/0')::bigint, "
"pg_catalog.pg_read_binary_file('pg_replslot/' || slot_name || '/state')"
" FROM pg_catalog.pg_get_replication_slots() WHERE NOT pg_catalog.pg_is_in_recovery()"
" AND slot_name = ANY(%s)", (slots,))
slots = {r[0]: {'catalog_xmin': r[1], 'confirmed_flush_lsn': r[2], 'data': r[3]} for r in cur}
except Exception as e:
logger.error("Failed to copy logical slots from the %s via postgresql connection: %r", leader.name, e)
if isinstance(slots, dict) and self._postgresql.stop():
pg_replslot_dir = os.path.join(self._postgresql.data_dir, 'pg_replslot')
for name, value in slots.items():
slot_dir = os.path.join(pg_replslot_dir, name)
slot_tmp_dir = slot_dir + '.tmp'
if os.path.exists(slot_tmp_dir):
shutil.rmtree(slot_tmp_dir)
os.makedirs(slot_tmp_dir)
fsync_dir(slot_tmp_dir)
with open(os.path.join(slot_tmp_dir, 'state'), 'wb') as f:
f.write(value['data'])
f.flush()
os.fsync(f.fileno())
if os.path.exists(slot_dir):
shutil.rmtree(slot_dir)
os.rename(slot_tmp_dir, slot_dir)
fsync_dir(slot_dir)
self._unready_logical_slots.add(name)
fsync_dir(pg_replslot_dir)
self._postgresql.start()
def schedule(self, value=None): def schedule(self, value=None):
if value is None: if value is None:
value = self._postgresql.major_version >= 90400 value = self._postgresql.major_version >= 90400
self._schedule_load_slots = value self._schedule_load_slots = self._force_readiness_check = value
def on_promote(self):
if self._unready_logical_slots:
logger.warning('Logical replication slots that might be unsafe to use after promote: %s',
self._unready_logical_slots)
+23 -4
View File
@@ -108,7 +108,7 @@ parameters = CaseInsensitiveDict({
), ),
'archive_timeout': Integer(90300, None, 0, 1073741823, 's'), 'archive_timeout': Integer(90300, None, 0, 1073741823, 's'),
'array_nulls': Bool(90300, None), 'array_nulls': Bool(90300, None),
'authentication_timeout': Integer(90300, None, 1, 600, 's'), 'authentication_timeout': Integer(90300, None, 1, 600, 's'),
'autovacuum': Bool(90300, None), 'autovacuum': Bool(90300, None),
'autovacuum_analyze_scale_factor': Real(90300, None, 0, 100, None), 'autovacuum_analyze_scale_factor': Real(90300, None, 0, 100, None),
'autovacuum_analyze_threshold': Integer(90300, None, 0, 2147483647, None), 'autovacuum_analyze_threshold': Integer(90300, None, 0, 2147483647, None),
@@ -151,12 +151,14 @@ 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_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')),
'cluster_name': String(90500, None), 'cluster_name': String(90500, None),
'commit_delay': Integer(90300, None, 0, 100000, None), 'commit_delay': Integer(90300, None, 0, 100000, None),
'commit_siblings': Integer(90300, None, 0, 1000, None), 'commit_siblings': Integer(90300, None, 0, 1000, None),
'compute_query_id': EnumBool(140000, None, ('auto',)),
'config_file': String(90300, None), 'config_file': String(90300, None),
'constraint_exclusion': EnumBool(90300, None, ('partition',)), 'constraint_exclusion': EnumBool(90300, None, ('partition',)),
'cpu_index_tuple_cost': Real(90300, None, 0, 1.79769e+308, None), 'cpu_index_tuple_cost': Real(90300, None, 0, 1.79769e+308, None),
@@ -168,6 +170,7 @@ parameters = CaseInsensitiveDict({
'DateStyle': String(90300, None), 'DateStyle': String(90300, None),
'db_user_namespace': Bool(90300, None), 'db_user_namespace': Bool(90300, None),
'deadlock_timeout': Integer(90300, None, 1, 2147483647, 'ms'), 'deadlock_timeout': Integer(90300, None, 1, 2147483647, 'ms'),
'debug_invalidate_system_caches_always': Integer(140000, None, '0', '0', None),
'debug_pretty_print': Bool(90300, None), 'debug_pretty_print': Bool(90300, None),
'debug_print_parse': Bool(90300, None), 'debug_print_parse': Bool(90300, None),
'debug_print_plan': Bool(90300, None), 'debug_print_plan': Bool(90300, None),
@@ -176,6 +179,7 @@ parameters = CaseInsensitiveDict({
'default_table_access_method': String(120000, None), 'default_table_access_method': String(120000, None),
'default_tablespace': String(90300, None), 'default_tablespace': String(90300, None),
'default_text_search_config': String(90300, None), 'default_text_search_config': String(90300, None),
'default_toast_compression': Enum(140000, None, ('pglz', 'lz4')),
'default_transaction_deferrable': Bool(90300, None), 'default_transaction_deferrable': Bool(90300, None),
'default_transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read', 'default_transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
'read committed', 'read uncommitted')), 'read committed', 'read uncommitted')),
@@ -188,6 +192,7 @@ parameters = CaseInsensitiveDict({
), ),
'effective_cache_size': Integer(90300, None, 1, 2147483647, '8kB'), 'effective_cache_size': Integer(90300, None, 1, 2147483647, '8kB'),
'effective_io_concurrency': Integer(90300, None, 0, 1000, None), 'effective_io_concurrency': Integer(90300, None, 0, 1000, None),
'enable_async_append': Bool(140000, None),
'enable_bitmapscan': Bool(90300, None), 'enable_bitmapscan': Bool(90300, None),
'enable_gathermerge': Bool(100000, None), 'enable_gathermerge': Bool(100000, None),
'enable_hashagg': Bool(90300, None), 'enable_hashagg': Bool(90300, None),
@@ -203,12 +208,14 @@ parameters = CaseInsensitiveDict({
'enable_partition_pruning': Bool(110000, None), 'enable_partition_pruning': Bool(110000, None),
'enable_partitionwise_aggregate': Bool(110000, None), 'enable_partitionwise_aggregate': Bool(110000, None),
'enable_partitionwise_join': Bool(110000, None), 'enable_partitionwise_join': Bool(110000, None),
'enable_resultcache': Bool(140000, None),
'enable_seqscan': Bool(90300, None), 'enable_seqscan': Bool(90300, None),
'enable_sort': Bool(90300, None), 'enable_sort': Bool(90300, None),
'enable_tidscan': Bool(90300, None), 'enable_tidscan': Bool(90300, None),
'escape_string_warning': Bool(90300, None), 'escape_string_warning': Bool(90300, None),
'event_source': String(90300, None), 'event_source': String(90300, None),
'exit_on_error': Bool(90300, None), 'exit_on_error': Bool(90300, None),
'extension_destdir': String(140000, None),
'external_pid_file': String(90300, None), 'external_pid_file': String(90300, None),
'extra_float_digits': Integer(90300, None, -15, 3, None), 'extra_float_digits': Integer(90300, None, -15, 3, None),
'force_parallel_mode': EnumBool(90600, None, ('regress',)), 'force_parallel_mode': EnumBool(90600, None, ('regress',)),
@@ -229,8 +236,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'),
'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'),
'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),
@@ -283,6 +292,7 @@ parameters = CaseInsensitiveDict({
'log_parameter_max_length_on_error': Integer(130000, None, -1, 1073741823, 'B'), 'log_parameter_max_length_on_error': Integer(130000, None, -1, 1073741823, 'B'),
'log_parser_stats': Bool(90300, None), 'log_parser_stats': Bool(90300, None),
'log_planner_stats': Bool(90300, None), 'log_planner_stats': Bool(90300, None),
'log_recovery_conflict_waits': Bool(140000, None),
'log_replication_commands': Bool(90500, None), 'log_replication_commands': Bool(90500, None),
'log_rotation_age': Integer(90300, None, 0, 35791394, 'min'), 'log_rotation_age': Integer(90300, None, 0, 35791394, 'min'),
'log_rotation_size': Integer(90300, None, 0, 2097151, 'kB'), 'log_rotation_size': Integer(90300, None, 0, 2097151, 'kB'),
@@ -336,6 +346,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_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'),
@@ -344,7 +355,7 @@ parameters = CaseInsensitiveDict({
Integer(100000, None, 2, 2147483647, 'MB') Integer(100000, None, 2, 2147483647, 'MB')
), ),
'old_snapshot_threshold': Integer(90600, None, -1, 86400, 'min'), 'old_snapshot_threshold': Integer(90600, None, -1, 86400, 'min'),
'operator_precedence_warning': Bool(90500, None), 'operator_precedence_warning': Bool(90500, 140000),
'parallel_leader_participation': Bool(110000, None), 'parallel_leader_participation': Bool(110000, None),
'parallel_setup_cost': Real(90600, None, 0, 1.79769e+308, None), 'parallel_setup_cost': Real(90600, None, 0, 1.79769e+308, None),
'parallel_tuple_cost': Real(90600, None, 0, 1.79769e+308, None), 'parallel_tuple_cost': Real(90600, None, 0, 1.79769e+308, None),
@@ -358,6 +369,8 @@ parameters = CaseInsensitiveDict({
'pre_auth_delay': Integer(90300, None, 0, 60, 's'), 'pre_auth_delay': Integer(90300, None, 0, 60, 's'),
'quote_all_identifiers': Bool(90300, None), 'quote_all_identifiers': Bool(90300, None),
'random_page_cost': Real(90300, None, 0, 1.79769e+308, None), 'random_page_cost': Real(90300, None, 0, 1.79769e+308, None),
'recovery_init_sync_method': Enum(140000, None, ('fsync', 'syncfs')),
'remove_temp_files_after_crash': Bool(140000, None),
'replacement_sort_tuples': Integer(90600, 110000, 0, 2147483647, None), 'replacement_sort_tuples': Integer(90600, 110000, 0, 2147483647, None),
'restart_after_crash': Bool(90300, None), 'restart_after_crash': Bool(90300, None),
'row_security': Bool(90500, None), 'row_security': Bool(90500, None),
@@ -373,6 +386,7 @@ parameters = CaseInsensitiveDict({
'ssl_ca_file': String(90300, None), 'ssl_ca_file': String(90300, None),
'ssl_cert_file': String(90300, None), 'ssl_cert_file': String(90300, None),
'ssl_ciphers': String(90300, None), 'ssl_ciphers': String(90300, None),
'ssl_crl_dir': String(140000, None),
'ssl_crl_file': String(90300, None), 'ssl_crl_file': String(90300, None),
'ssl_dh_params_file': String(100000, None), 'ssl_dh_params_file': String(100000, None),
'ssl_ecdh_curve': String(90400, None), 'ssl_ecdh_curve': String(90400, None),
@@ -388,7 +402,7 @@ parameters = CaseInsensitiveDict({
'stats_temp_directory': String(90300, None), 'stats_temp_directory': String(90300, None),
'superuser_reserved_connections': ( 'superuser_reserved_connections': (
Integer(90300, 90600, 0, 8388607, None), Integer(90300, 90600, 0, 8388607, None),
Integer(90600, None, 0, 262143, None), Integer(90600, None, 0, 262143, None)
), ),
'synchronize_seqscans': Bool(90300, None), 'synchronize_seqscans': Bool(90300, None),
'synchronous_commit': ( 'synchronous_commit': (
@@ -424,6 +438,7 @@ parameters = CaseInsensitiveDict({
'track_counts': Bool(90300, None), 'track_counts': Bool(90300, None),
'track_functions': Enum(90300, None, ('none', 'pl', 'all')), 'track_functions': Enum(90300, None, ('none', 'pl', 'all')),
'track_io_timing': Bool(90300, None), 'track_io_timing': Bool(90300, None),
'track_wal_io_timing': Bool(140000, None),
'transaction_deferrable': Bool(90300, None), 'transaction_deferrable': Bool(90300, None),
'transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read', 'transaction_isolation': Enum(90300, None, ('serializable', 'repeatable read',
'read committed', 'read uncommitted')), 'read committed', 'read uncommitted')),
@@ -433,7 +448,7 @@ parameters = CaseInsensitiveDict({
'unix_socket_group': String(90300, None), 'unix_socket_group': String(90300, None),
'unix_socket_permissions': Integer(90300, None, 0, 511, None), 'unix_socket_permissions': Integer(90300, None, 0, 511, None),
'update_process_title': Bool(90300, None), 'update_process_title': Bool(90300, None),
'vacuum_cleanup_index_scale_factor': Real(110000, None, 0, 1e+10, None), 'vacuum_cleanup_index_scale_factor': Real(110000, 140000, 0, 1e+10, None),
'vacuum_cost_delay': ( 'vacuum_cost_delay': (
Integer(90300, 120000, 0, 100, 'ms'), Integer(90300, 120000, 0, 100, 'ms'),
Real(120000, None, 0, 100, 'ms') Real(120000, None, 0, 100, 'ms')
@@ -443,8 +458,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_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_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'),
@@ -511,6 +528,8 @@ def _transform_parameter_value(validators, version, name, value):
def transform_postgresql_parameter_value(version, name, value): def transform_postgresql_parameter_value(version, name, value):
if '.' in name: if '.' in name:
return value return value
if name in recovery_parameters:
return None
return _transform_parameter_value(parameters, version, name, value) return _transform_parameter_value(parameters, version, name, value)
+6 -11
View File
@@ -1,9 +1,7 @@
import logging import logging
import os
from patroni.daemon import AbstractPatroniDaemon, abstract_main from .daemon import AbstractPatroniDaemon, abstract_main
from patroni.dcs.raft import KVStoreTTL from .dcs.raft import KVStoreTTL
from pysyncobj import SyncObjConf
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,16 +11,13 @@ class RaftController(AbstractPatroniDaemon):
def __init__(self, config): def __init__(self, config):
super(RaftController, self).__init__(config) super(RaftController, self).__init__(config)
raft_config = self.config.get('raft') config = self.config.get('raft')
self_addr = raft_config['self_addr'] assert 'self_addr' in config
template = os.path.join(raft_config.get('data_dir', ''), self_addr) self._raft = KVStoreTTL(None, None, None, **config)
self._syncobj_config = SyncObjConf(autoTick=False, appendEntriesUseBatch=False, dynamicMembershipChange=True,
journalFile=template + '.journal', fullDumpFile=template + '.dump')
self._raft = KVStoreTTL(self_addr, raft_config.get('partner_addrs', []), self._syncobj_config)
def _run_cycle(self): def _run_cycle(self):
try: try:
self._raft.doTick(self._syncobj_config.autoTickPeriod) self._raft.doTick(self._raft.conf.autoTickPeriod)
except Exception: except Exception:
logger.exception('doTick') logger.exception('doTick')
+2 -2
View File
@@ -250,7 +250,7 @@ class WALERestore(object):
diff_in_bytes = 0 diff_in_bytes = 0
break break
# if the size of the accumulated WAL segments is more than a certan percentage of the backup size # if the size of the accumulated WAL segments is more than a certain percentage of the backup size
# or exceeds the pre-determined size - pg_basebackup is chosen instead. # or exceeds the pre-determined size - pg_basebackup is chosen instead.
is_size_thresh_ok = diff_in_bytes < int(threshold_megabytes) * 1048576 is_size_thresh_ok = diff_in_bytes < int(threshold_megabytes) * 1048576
threshold_pct_bytes = backup_size * threshold_percent / 100.0 threshold_pct_bytes = backup_size * threshold_percent / 100.0
@@ -308,7 +308,7 @@ class WALERestore(object):
try: try:
os.mkdir(path) os.mkdir(path)
except OSError: except OSError:
logger.exception("coud not create missing %s directory path", dirname) logger.exception("could not create missing %s directory path", dirname)
return False return False
return True return True
+5 -5
View File
@@ -405,7 +405,7 @@ def is_standby_cluster(config):
def cluster_as_json(cluster): def cluster_as_json(cluster):
leader_name = cluster.leader.name if cluster.leader else None leader_name = cluster.leader.name if cluster.leader else None
xlog_location_cluster = cluster.last_leader_operation or 0 cluster_lsn = cluster.last_lsn or 0
ret = {'members': []} ret = {'members': []}
for m in cluster.members: for m in cluster.members:
@@ -427,11 +427,11 @@ def cluster_as_json(cluster):
member.update({n: m.data[n] for n in optional_attributes if n in m.data}) member.update({n: m.data[n] for n in optional_attributes if n in m.data})
if m.name != leader_name: if m.name != leader_name:
xlog_location = m.data.get('xlog_location') lsn = m.data.get('xlog_location')
if xlog_location is None: if lsn is None:
member['lag'] = 'unknown' member['lag'] = 'unknown'
elif xlog_location_cluster >= xlog_location: elif cluster_lsn >= lsn:
member['lag'] = xlog_location_cluster - xlog_location member['lag'] = cluster_lsn - lsn
else: else:
member['lag'] = 0 member['lag'] = 0
+1 -1
View File
@@ -1 +1 @@
__version__ = '2.0.2' __version__ = '2.1.0'
+5
View File
@@ -57,10 +57,15 @@ bootstrap:
parameters: parameters:
# wal_level: hot_standby # wal_level: hot_standby
# hot_standby: "on" # hot_standby: "on"
# max_connections: 100
# max_worker_processes: 8
# wal_keep_segments: 8 # wal_keep_segments: 8
# max_wal_senders: 10 # max_wal_senders: 10
# max_replication_slots: 10 # max_replication_slots: 10
# max_prepared_transactions: 0
# max_locks_per_transaction: 64
# wal_log_hints: "on" # wal_log_hints: "on"
# track_commit_timestamp: "off"
# archive_mode: "on" # archive_mode: "on"
# archive_timeout: 1800s # archive_timeout: 1800s
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f # archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
+5
View File
@@ -51,10 +51,15 @@ bootstrap:
parameters: parameters:
# wal_level: hot_standby # wal_level: hot_standby
# hot_standby: "on" # hot_standby: "on"
# max_connections: 100
# max_worker_processes: 8
# wal_keep_segments: 8 # wal_keep_segments: 8
# max_wal_senders: 10 # max_wal_senders: 10
# max_replication_slots: 10 # max_replication_slots: 10
# max_prepared_transactions: 0
# max_locks_per_transaction: 64
# wal_log_hints: "on" # wal_log_hints: "on"
# track_commit_timestamp: "off"
# archive_mode: "on" # archive_mode: "on"
# archive_timeout: 1800s # archive_timeout: 1800s
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f # archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
+5
View File
@@ -51,10 +51,15 @@ bootstrap:
parameters: parameters:
# wal_level: hot_standby # wal_level: hot_standby
# hot_standby: "on" # hot_standby: "on"
# max_connections: 100
# max_worker_processes: 8
# wal_keep_segments: 8 # wal_keep_segments: 8
# max_wal_senders: 10 # max_wal_senders: 10
# max_replication_slots: 10 # max_replication_slots: 10
# max_prepared_transactions: 0
# max_locks_per_transaction: 64
# wal_log_hints: "on" # wal_log_hints: "on"
# track_commit_timestamp: "off"
# archive_mode: "on" # archive_mode: "on"
# archive_timeout: 1800s # archive_timeout: 1800s
# archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f # archive_command: mkdir -p ../wal_archive && test ! -f ../wal_archive/%f && cp %p ../wal_archive/%f
+2 -1
View File
@@ -9,6 +9,7 @@ python-consul>=0.7.1
click>=4.1 click>=4.1
prettytable>=0.7 prettytable>=0.7
python-dateutil python-dateutil
pysyncobj>=0.3.7 pysyncobj>=0.3.8
cryptography>=1.4
psutil>=2.0.0 psutil>=2.0.0
ydiff>=1.2.0 ydiff>=1.2.0
+12 -6
View File
@@ -22,8 +22,9 @@ AUTHOR_EMAIL = '[email protected], [email protected], alexk
KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\ KEYWORDS = 'etcd governor patroni postgresql postgres ha haproxy confd' +\
' zookeeper exhibitor consul streaming replication kubernetes k8s' ' zookeeper exhibitor consul streaming replication kubernetes k8s'
EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'], 'consul': ['python-consul'], EXTRAS_REQUIRE = {'aws': ['boto'], 'etcd': ['python-etcd'], 'etcd3': ['python-etcd'],
'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'], 'kubernetes': ['ipaddress'], 'raft': ['pysyncobj']} 'consul': ['python-consul'], 'exhibitor': ['kazoo'], 'zookeeper': ['kazoo'],
'kubernetes': [], 'raft': ['pysyncobj', 'cryptography']}
COVERAGE_XML = True COVERAGE_XML = True
COVERAGE_HTML = False COVERAGE_HTML = False
@@ -171,10 +172,15 @@ def setup_package(version):
if r == '': if r == '':
continue continue
extra = False extra = False
for e, v in EXTRAS_REQUIRE.items(): for e, deps in EXTRAS_REQUIRE.items():
if v and r.startswith(v[0]): for i, v in enumerate(deps):
EXTRAS_REQUIRE[e] = [r] if e != 'kubernetes' or sys.version_info < (3, 0, 0) else [] if r.startswith(v):
extra = True deps[i] = r
EXTRAS_REQUIRE[e] = deps
extra = True
break
if extra:
break
if not extra: if not extra:
install_requires.append(r) install_requires.append(r)
+11 -5
View File
@@ -1,3 +1,4 @@
import datetime
import os import os
import shutil import shutil
import unittest import unittest
@@ -10,7 +11,7 @@ import urllib3
from patroni.dcs import Leader, Member from patroni.dcs import Leader, Member
from patroni.postgresql import Postgresql from patroni.postgresql import Postgresql
from patroni.postgresql.config import ConfigHandler from patroni.postgresql.config import ConfigHandler
from patroni.utils import RetryFailedError from patroni.utils import RetryFailedError, tzutc
class SleepException(Exception): class SleepException(Exception):
@@ -89,16 +90,21 @@ class MockCursor(object):
raise psycopg2.OperationalError() raise psycopg2.OperationalError()
elif sql.startswith('RetryFailedError'): elif sql.startswith('RetryFailedError'):
raise RetryFailedError('retry') raise RetryFailedError('retry')
elif sql.startswith('SELECT catalog_xmin'):
self.results = [(100, 501)]
elif sql.startswith('SELECT slot_name, catalog_xmin'):
self.results = [('ls', 100, 500, b'123456')]
elif sql.startswith('SELECT slot_name'): elif sql.startswith('SELECT slot_name'):
self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b')] self.results = [('blabla', 'physical'), ('foobar', 'physical'), ('ls', 'logical', 'a', 'b', 5, 100, 500)]
elif sql.startswith('SELECT CASE WHEN pg_catalog.pg_is_in_recovery()'): elif sql.startswith('SELECT CASE WHEN pg_catalog.pg_is_in_recovery()'):
self.results = [(1, 2, 1, 0, False, 1, 1, None, None)] self.results = [(1, 2, 1, 0, False, 1, 1, None, None, [{"slot_name": "ls", "confirmed_flush_lsn": 12345}])]
elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'): elif sql.startswith('SELECT pg_catalog.pg_is_in_recovery()'):
self.results = [(False, 2)] self.results = [(False, 2)]
elif sql.startswith('SELECT pg_catalog.to_char'): elif sql.startswith('SELECT pg_catalog.pg_postmaster_start_time'):
replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\ replication_info = '[{"application_name":"walreceiver","client_addr":"1.2.3.4",' +\
'"state":"streaming","sync_state":"async","sync_priority":0}]' '"state":"streaming","sync_state":"async","sync_priority":0}]'
self.results = [('', 0, '', 0, '', '', False, replication_info)] now = datetime.datetime.now(tzutc)
self.results = [(now, 0, '', 0, '', False, now, replication_info)]
elif sql.startswith('SELECT name, setting'): elif sql.startswith('SELECT name, setting'):
self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'), self.results = [('wal_segment_size', '2048', '8kB', 'integer', 'internal'),
('wal_block_size', '8192', None, 'integer', 'internal'), ('wal_block_size', '8192', None, 'integer', 'internal'),
+108 -8
View File
@@ -30,7 +30,7 @@ class MockPostgresql(object):
pending_restart = True pending_restart = True
wal_name = 'wal' wal_name = 'wal'
lsn_name = 'lsn' lsn_name = 'lsn'
POSTMASTER_START_TIME = 'pg_catalog.to_char(pg_catalog.pg_postmaster_start_time' POSTMASTER_START_TIME = 'pg_catalog.pg_postmaster_start_time()'
TL_LSN = 'CASE WHEN pg_catalog.pg_is_in_recovery()' TL_LSN = 'CASE WHEN pg_catalog.pg_is_in_recovery()'
@staticmethod @staticmethod
@@ -39,7 +39,7 @@ class MockPostgresql(object):
@staticmethod @staticmethod
def postmaster_start_time(): def postmaster_start_time():
return str(postmaster_start_time) return postmaster_start_time
@staticmethod @staticmethod
def replica_cached_timeline(_): def replica_cached_timeline(_):
@@ -118,7 +118,7 @@ class MockPatroni(object):
postgresql = ha.state_handler postgresql = ha.state_handler
dcs = Mock() dcs = Mock()
logger = MockLogger() logger = MockLogger()
tags = {} tags = {"key1": True, "key2": False, "key3": 1, "key4": 1.4, "key5": "RandomTag"}
version = '0.00' version = '0.00'
noloadbalance = PropertyMock(return_value=False) noloadbalance = PropertyMock(return_value=False)
scheduled_restart = {'schedule': future_restart_time, scheduled_restart = {'schedule': future_restart_time,
@@ -162,7 +162,7 @@ class TestRestApiHandler(unittest.TestCase):
_authorization = '\nAuthorization: Basic dGVzdDp0ZXN0' _authorization = '\nAuthorization: Basic dGVzdDp0ZXN0'
def test_do_GET(self): def test_do_GET(self):
MockPatroni.dcs.cluster.last_leader_operation = 20 MockPatroni.dcs.cluster.last_lsn = 20
MockRestApiServer(RestApiHandler, 'GET /replica') MockRestApiServer(RestApiHandler, 'GET /replica')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M') MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB') MockRestApiServer(RestApiHandler, 'GET /replica?lag=10MB')
@@ -174,7 +174,7 @@ class TestRestApiHandler(unittest.TestCase):
MockRestApiServer(RestApiHandler, 'GET /replica') MockRestApiServer(RestApiHandler, 'GET /replica')
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'state': 'running'})): with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'state': 'running'})):
MockRestApiServer(RestApiHandler, 'GET /health') MockRestApiServer(RestApiHandler, 'GET /health')
MockRestApiServer(RestApiHandler, 'GET /master') MockRestApiServer(RestApiHandler, 'GET /leader')
MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name] MockPatroni.dcs.cluster.sync.members = [MockPostgresql.name]
MockPatroni.dcs.cluster.is_synchronous_mode = Mock(return_value=True) MockPatroni.dcs.cluster.is_synchronous_mode = Mock(return_value=True)
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})): with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'replica'})):
@@ -197,6 +197,89 @@ class TestRestApiHandler(unittest.TestCase):
with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)): with patch.object(MockHa, 'is_standby_cluster', Mock(return_value=True)):
MockRestApiServer(RestApiHandler, 'GET /standby_leader') MockRestApiServer(RestApiHandler, 'GET /standby_leader')
# test tags
#
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
'tag_key1=true&tag_key2=False&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
#
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
'tag_key1=true&tag_key2=False&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /master?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
#
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'standby_leader'})):
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
'tag_key1=true&tag_key2=False&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /standby_leader?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
#
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
'tag_key1=true&tag_key2=False&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
#
with patch.object(RestApiHandler, 'get_postgresql_status', Mock(return_value={'role': 'master'})):
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
'tag_key1=true&tag_key2=False&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /replica?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
#
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
'tag_key1=true&tag_key2=False&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1.0&tag_key4=1.4&tag_key5=RandomTag')
MockRestApiServer(RestApiHandler, 'GET /read-write?lag=1M&'
'tag_key1=true&tag_key2=false&'
'tag_key3=1&tag_key4=1.4&tag_key5=RandomTag&tag_key6=RandomTag2')
def test_do_OPTIONS(self): def test_do_OPTIONS(self):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0')) self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'OPTIONS / HTTP/1.0'))
@@ -236,6 +319,10 @@ class TestRestApiHandler(unittest.TestCase):
mock_dcs.cluster.config = None mock_dcs.cluster.config = None
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /config')) self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /config'))
@patch.object(MockPatroni, 'dcs')
def test_do_GET_metrics(self, mock_dcs):
self.assertIsNotNone(MockRestApiServer(RestApiHandler, 'GET /metrics'))
@patch.object(MockPatroni, 'dcs') @patch.object(MockPatroni, 'dcs')
def test_do_PATCH_config(self, mock_dcs): def test_do_PATCH_config(self, mock_dcs):
config = {'postgresql': {'use_slots': False, 'use_pg_rewind': True, 'parameters': {'wal_level': 'logical'}}} config = {'postgresql': {'use_slots': False, 'use_pg_rewind': True, 'parameters': {'wal_level': 'logical'}}}
@@ -451,7 +538,9 @@ class TestRestApiServer(unittest.TestCase):
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock()) @patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
def setUp(self): def setUp(self):
self.srv = MockRestApiServer(Mock(), '', {'listen': '*:8008', 'certfile': 'a', 'verify_client': 'required', self.srv = MockRestApiServer(Mock(), '', {'listen': '*:8008', 'certfile': 'a', 'verify_client': 'required',
'ciphers': '!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1'}) 'ciphers': '!SSLv1:!SSLv2:!SSLv3:!TLSv1:!TLSv1.1',
'allowlist': ['127.0.0.1', '::1/128', '::1/zxc'],
'allowlist_include_members': True})
@patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock()) @patch.object(BaseHTTPServer.HTTPServer, '__init__', Mock())
def test_reload_config(self): def test_reload_config(self):
@@ -462,10 +551,17 @@ class TestRestApiServer(unittest.TestCase):
with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)): with patch.object(socket.socket, 'setsockopt', Mock(side_effect=socket.error)):
self.srv.reload_config({'listen': ':8008'}) self.srv.reload_config({'listen': ':8008'})
def test_check_auth(self): @patch.object(MockPatroni, 'dcs')
def test_check_access(self, mock_dcs):
mock_dcs.cluster = get_cluster_initialized_without_leader()
mock_dcs.cluster.members[1].data['api_url'] = 'http://127.0.0.1z:8011/patroni'
mock_dcs.cluster.members.append(Member(0, 'bad-api-url', 30, {'api_url': 123}))
mock_rh = Mock() mock_rh = Mock()
mock_rh.client_address = ('127.0.0.2',)
self.assertIsNot(self.srv.check_access(mock_rh), True)
mock_rh.client_address = ('127.0.0.1',)
mock_rh.request.getpeercert.return_value = None mock_rh.request.getpeercert.return_value = None
self.assertIsNot(self.srv.check_auth(mock_rh), True) self.assertIsNot(self.srv.check_access(mock_rh), True)
def test_handle_error(self): def test_handle_error(self):
try: try:
@@ -503,3 +599,7 @@ class TestRestApiServer(unittest.TestCase):
Mock(return_value=(mock_request, mock_address)) Mock(return_value=(mock_request, mock_address))
): ):
self.srv._handle_request_noblock() self.srv._handle_request_noblock()
@patch('ssl._ssl._test_decode_cert', Mock())
def test_reload_local_certificate(self):
self.assertTrue(self.srv.reload_local_certificate())
+2
View File
@@ -30,12 +30,14 @@ class TestConfig(unittest.TestCase):
'PATRONI_SCOPE': 'batman2', 'PATRONI_SCOPE': 'batman2',
'PATRONI_LOGLEVEL': 'ERROR', 'PATRONI_LOGLEVEL': 'ERROR',
'PATRONI_LOG_LOGGERS': 'patroni.postmaster: WARNING, urllib3: DEBUG', 'PATRONI_LOG_LOGGERS': 'patroni.postmaster: WARNING, urllib3: DEBUG',
'PATRONI_LOG_FILE_NUM': '5',
'PATRONI_RESTAPI_USERNAME': 'username', 'PATRONI_RESTAPI_USERNAME': 'username',
'PATRONI_RESTAPI_PASSWORD': 'password', 'PATRONI_RESTAPI_PASSWORD': 'password',
'PATRONI_RESTAPI_LISTEN': '0.0.0.0:8008', 'PATRONI_RESTAPI_LISTEN': '0.0.0.0:8008',
'PATRONI_RESTAPI_CONNECT_ADDRESS': '127.0.0.1:8008', 'PATRONI_RESTAPI_CONNECT_ADDRESS': '127.0.0.1:8008',
'PATRONI_RESTAPI_CERTFILE': '/certfile', 'PATRONI_RESTAPI_CERTFILE': '/certfile',
'PATRONI_RESTAPI_KEYFILE': '/keyfile', 'PATRONI_RESTAPI_KEYFILE': '/keyfile',
'PATRONI_RESTAPI_ALLOWLIST_INCLUDE_MEMBERS': 'on',
'PATRONI_POSTGRESQL_LISTEN': '0.0.0.0:5432', 'PATRONI_POSTGRESQL_LISTEN': '0.0.0.0:5432',
'PATRONI_POSTGRESQL_CONNECT_ADDRESS': '127.0.0.1:5432', 'PATRONI_POSTGRESQL_CONNECT_ADDRESS': '127.0.0.1:5432',
'PATRONI_POSTGRESQL_DATA_DIR': 'data/postgres0', 'PATRONI_POSTGRESQL_DATA_DIR': 'data/postgres0',
+17 -4
View File
@@ -15,8 +15,7 @@ def kv_get(self, key, **kwargs):
return None, None return None, None
if key == 'service/good/leader': if key == 'service/good/leader':
return '1', None return '1', None
if key == 'service/good/': good_cls = ('6429',
return ('6429',
[{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'failover', 'LockIndex': 0, [{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'failover', 'LockIndex': 0,
'ModifyIndex': 1334, 'Value': b''}, 'ModifyIndex': 1334, 'Value': b''},
{'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'initialize', 'LockIndex': 0, {'CreateIndex': 1334, 'Flags': 0, 'Key': key + 'initialize', 'LockIndex': 0,
@@ -34,7 +33,17 @@ def kv_get(self, key, **kwargs):
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'optime/leader', 'LockIndex': 0, {'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'optime/leader', 'LockIndex': 0,
'ModifyIndex': 6429, 'Value': b'4496294792'}, 'ModifyIndex': 6429, 'Value': b'4496294792'},
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'sync', 'LockIndex': 0, {'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'sync', 'LockIndex': 0,
'ModifyIndex': 6429, 'Value': b'{"leader": "leader", "sync_standby": null}'}]) 'ModifyIndex': 6429, 'Value': b'{"leader": "leader", "sync_standby": null}'},
{'CreateIndex': 1085, 'Flags': 0, 'Key': key + 'status', 'LockIndex': 0,
'ModifyIndex': 6429, 'Value': b'{"optime":4496294792, "slots":{"ls":12345}}'}])
if key == 'service/good/':
return good_cls
if key == 'service/broken/':
good_cls[1][-1]['Value'] = b'{'
return good_cls
if key == 'service/legacy/':
good_cls[1].pop()
return good_cls
raise ConsulException raise ConsulException
@@ -109,6 +118,10 @@ class TestConsul(unittest.TestCase):
self.assertIsInstance(self.c.get_cluster(), Cluster) self.assertIsInstance(self.c.get_cluster(), Cluster)
self.c._base_path = '/service/fail' self.c._base_path = '/service/fail'
self.assertRaises(ConsulError, self.c.get_cluster) self.assertRaises(ConsulError, self.c.get_cluster)
self.c._base_path = '/service/broken'
self.assertIsInstance(self.c.get_cluster(), Cluster)
self.c._base_path = '/service/legacy'
self.assertIsInstance(self.c.get_cluster(), Cluster)
self.c._base_path = '/service/good' self.c._base_path = '/service/good'
self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8' self.c._session = 'fd4f44fe-2cac-bba5-a60b-304b51ff39b8'
self.assertIsInstance(self.c.get_cluster(), Cluster) self.assertIsInstance(self.c.get_cluster(), Cluster)
@@ -146,7 +159,7 @@ class TestConsul(unittest.TestCase):
@patch.object(consul.Consul.Session, 'renew', Mock()) @patch.object(consul.Consul.Session, 'renew', Mock())
def test_update_leader(self): def test_update_leader(self):
self.c.update_leader(None) self.c.update_leader(12345)
@patch.object(consul.Consul.KV, 'delete', Mock(return_value=True)) @patch.object(consul.Consul.KV, 'delete', Mock(return_value=True))
def test_delete_leader(self): def test_delete_leader(self):
+11 -1
View File
@@ -66,7 +66,13 @@ def etcd_read(self, key, **kwargs):
"?application_name=http://127.0.0.1:8008/patroni", "?application_name=http://127.0.0.1:8008/patroni",
"expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30, "expiration": "2015-05-15T09:11:09.611860899Z", "ttl": 30,
"modifiedIndex": 20730, "createdIndex": 20730}], "modifiedIndex": 20730, "createdIndex": 20730}],
"modifiedIndex": 1581, "createdIndex": 1581}], "modifiedIndex": 1581, "createdIndex": 1581}} "modifiedIndex": 1581, "createdIndex": 1581},
{"key": "/service/batman5/status", "value": '{"optime":2164261704,"slots":{"ls":12345}}',
"modifiedIndex": 1582, "createdIndex": 1582}], "modifiedIndex": 1581, "createdIndex": 1581}}
if key == '/service/legacy/':
response['node']['nodes'].pop()
if key == '/service/broken/':
response['node']['nodes'][-1]['value'] = '{'
result = etcd.EtcdResult(**response) result = etcd.EtcdResult(**response)
result.etcd_index = 0 result.etcd_index = 0
return result return result
@@ -246,6 +252,10 @@ class TestEtcd(unittest.TestCase):
cluster = self.etcd.get_cluster() cluster = self.etcd.get_cluster()
self.assertIsInstance(cluster, Cluster) self.assertIsInstance(cluster, Cluster)
self.assertFalse(cluster.is_synchronous_mode()) self.assertFalse(cluster.is_synchronous_mode())
self.etcd._base_path = '/service/legacy'
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
self.etcd._base_path = '/service/broken'
self.assertIsInstance(self.etcd.get_cluster(), Cluster)
self.etcd._base_path = '/service/nocluster' self.etcd._base_path = '/service/nocluster'
cluster = self.etcd.get_cluster() cluster = self.etcd.get_cluster()
self.assertIsInstance(cluster, Cluster) self.assertIsInstance(cluster, Cluster)
+31 -2
View File
@@ -4,8 +4,9 @@ import unittest
import urllib3 import urllib3
from mock import Mock, patch from mock import Mock, patch
from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3, Etcd3Error, Etcd3ClientError, RetryFailedError,\ from patroni.dcs.etcd import DnsCachingResolver
InvalidAuthToken, Unavailable, Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode from patroni.dcs.etcd3 import PatroniEtcd3Client, Cluster, Etcd3Client, Etcd3Error, Etcd3ClientError, RetryFailedError,\
InvalidAuthToken, Unavailable, Unknown, UnsupportedEtcdVersion, UserEmpty, AuthFailed, base64_encode, Etcd3
from threading import Thread from threading import Thread
from . import SleepException, MockResponse from . import SleepException, MockResponse
@@ -33,6 +34,8 @@ def mock_urlopen(self, method, url, **kwargs):
"value": base64_encode('foo'), "lease": "bla", "mod_revision": '1'}, "value": base64_encode('foo'), "lease": "bla", "mod_revision": '1'},
{"key": base64_encode('/patroni/test/members/foo'), {"key": base64_encode('/patroni/test/members/foo'),
"value": base64_encode('{}'), "lease": "123", "mod_revision": '1'}, "value": base64_encode('{}'), "lease": "123", "mod_revision": '1'},
{"key": base64_encode('/patroni/test/members/bar'),
"value": base64_encode('{"version":"1.6.5"}'), "lease": "123", "mod_revision": '1'},
{"key": base64_encode('/patroni/test/failover'), "value": base64_encode('{}'), "mod_revision": '1'} {"key": base64_encode('/patroni/test/failover'), "value": base64_encode('{}'), "mod_revision": '1'}
] ]
}) })
@@ -55,6 +58,16 @@ def mock_urlopen(self, method, url, **kwargs):
return ret return ret
class TestEtcd3Client(unittest.TestCase):
@patch.object(Thread, 'start', Mock())
@patch.object(urllib3.PoolManager, 'urlopen', mock_urlopen)
def test_authenticate(self):
etcd3 = Etcd3Client({'host': '127.0.0.1', 'port': 2379, 'use_proxies': True, 'retry_timeout': 10},
DnsCachingResolver())
self.assertIsNotNone(etcd3._cluster_version)
class BaseTestEtcd3(unittest.TestCase): class BaseTestEtcd3(unittest.TestCase):
@patch.object(Thread, 'start', Mock()) @patch.object(Thread, 'start', Mock())
@@ -172,6 +185,22 @@ class TestEtcd3(BaseTestEtcd3):
self.assertIsInstance(self.etcd3.get_cluster(), Cluster) self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
self.client._kv_cache = None self.client._kv_cache = None
with patch.object(urllib3.PoolManager, 'urlopen') as mock_urlopen: with patch.object(urllib3.PoolManager, 'urlopen') as mock_urlopen:
mock_urlopen.return_value = MockResponse()
mock_urlopen.return_value.content = json.dumps({
"header": {"revision": "1"},
"kvs": [
{"key": base64_encode('/patroni/test/status'),
"value": base64_encode('{"optime":1234567,"slots":{"ls":12345}}'), "mod_revision": '1'}
]
})
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
mock_urlopen.return_value.content = json.dumps({
"header": {"revision": "1"},
"kvs": [
{"key": base64_encode('/patroni/test/status'), "value": base64_encode('{'), "mod_revision": '1'}
]
})
self.assertIsInstance(self.etcd3.get_cluster(), Cluster)
mock_urlopen.side_effect = UnsupportedEtcdVersion('') mock_urlopen.side_effect = UnsupportedEtcdVersion('')
self.assertRaises(UnsupportedEtcdVersion, self.etcd3.get_cluster) self.assertRaises(UnsupportedEtcdVersion, self.etcd3.get_cluster)
mock_urlopen.side_effect = SleepException() mock_urlopen.side_effect = SleepException()
+1 -1
View File
@@ -24,7 +24,7 @@ class TestExhibitor(unittest.TestCase):
@patch('urllib3.PoolManager.request', Mock(return_value=urllib3.HTTPResponse( @patch('urllib3.PoolManager.request', Mock(return_value=urllib3.HTTPResponse(
status=200, body=b'{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}'))) status=200, body=b'{"servers":["127.0.0.1","127.0.0.2","127.0.0.3"],"port":2181}')))
@patch('patroni.dcs.zookeeper.KazooClient', MockKazooClient) @patch('patroni.dcs.zookeeper.PatroniKazooClient', MockKazooClient)
def setUp(self): def setUp(self):
self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test', self.e = Exhibitor({'hosts': ['localhost', 'exhibitor'], 'port': 8181, 'scope': 'test',
'name': 'foo', 'ttl': 30, 'retry_timeout': 10}) 'name': 'foo', 'ttl': 30, 'retry_timeout': 10})
+86 -39
View File
@@ -38,7 +38,7 @@ def get_cluster(initialize, leader, members, failover, sync, cluster_config=None
history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '"]]', history = TimelineHistory(1, '[[1,67197376,"no recovery target specified","' + t + '"]]',
[(1, 67197376, 'no recovery target specified', t)]) [(1, 67197376, 'no recovery target specified', t)])
cluster_config = cluster_config or ClusterConfig(1, {'check_timeline': True}, 1) cluster_config = cluster_config or ClusterConfig(1, {'check_timeline': True}, 1)
return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history) return Cluster(initialize, cluster_config, leader, 10, members, failover, sync, history, None)
def get_cluster_not_initialized_without_leader(cluster_config=None): def get_cluster_not_initialized_without_leader(cluster_config=None):
@@ -66,7 +66,7 @@ def get_cluster_initialized_with_leader(failover=None, sync=None):
def get_cluster_initialized_with_only_leader(failover=None, cluster_config=None): def get_cluster_initialized_with_only_leader(failover=None, cluster_config=None):
leader = get_cluster_initialized_without_leader(leader=True, failover=failover).leader leader = get_cluster_initialized_without_leader(leader=True, failover=failover).leader
return get_cluster(True, leader, [leader], failover, None, cluster_config) return get_cluster(True, leader, [leader.member], failover, None, cluster_config)
def get_standby_cluster_initialized_with_only_leader(failover=None, sync=None): def get_standby_cluster_initialized_with_only_leader(failover=None, sync=None):
@@ -152,13 +152,14 @@ def run_async(self, func, args=()):
@patch.object(Postgresql, 'is_running', Mock(return_value=MockPostmaster())) @patch.object(Postgresql, 'is_running', Mock(return_value=MockPostmaster()))
@patch.object(Postgresql, 'is_leader', Mock(return_value=True)) @patch.object(Postgresql, 'is_leader', Mock(return_value=True))
@patch.object(Postgresql, 'timeline_wal_position', Mock(return_value=(1, 10, 1))) @patch.object(Postgresql, 'timeline_wal_position', Mock(return_value=(1, 10, 1)))
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=3)) @patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=10))
@patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False)) @patch.object(Postgresql, 'data_directory_empty', Mock(return_value=False))
@patch.object(Postgresql, 'controldata', Mock(return_value={ @patch.object(Postgresql, 'controldata', Mock(return_value={
'Database system identifier': SYSID, 'Database system identifier': SYSID,
'Database cluster state': 'shut down', 'Database cluster state': 'shut down',
'Latest checkpoint location': '0/12345678'})) 'Latest checkpoint location': '0/12345678',
@patch.object(SlotsHandler, 'sync_replication_slots', Mock()) "Latest checkpoint's TimeLineID": '2'}))
@patch.object(SlotsHandler, 'load_replication_slots', Mock(side_effect=Exception))
@patch.object(ConfigHandler, 'append_pg_hba', Mock()) @patch.object(ConfigHandler, 'append_pg_hba', Mock())
@patch.object(ConfigHandler, 'write_pgpass', Mock(return_value={})) @patch.object(ConfigHandler, 'write_pgpass', Mock(return_value={}))
@patch.object(ConfigHandler, 'write_recovery_conf', Mock()) @patch.object(ConfigHandler, 'write_recovery_conf', Mock())
@@ -250,6 +251,15 @@ class TestHa(PostgresInit):
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary') self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
self.assertEqual(self.ha.run_cycle(), 'failed to start postgres') self.assertEqual(self.ha.run_cycle(), 'failed to start postgres')
def test_recover_raft(self):
self.p.controldata = lambda: {'Database cluster state': 'in recovery', 'Database system identifier': SYSID}
self.p.is_running = false
self.p.follow = true
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
self.p.is_running = true
self.ha.dcs.__class__.__name__ = 'Raft'
self.assertEqual(self.ha.run_cycle(), 'started as a secondary')
def test_recover_former_master(self): def test_recover_former_master(self):
self.p.follow = false self.p.follow = false
self.p.is_running = false self.p.is_running = false
@@ -278,7 +288,17 @@ class TestHa(PostgresInit):
def test_recover_with_rewind(self): def test_recover_with_rewind(self):
self.p.is_running = false self.p.is_running = false
self.ha.cluster = get_cluster_initialized_with_leader() self.ha.cluster = get_cluster_initialized_with_leader()
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader') self.ha.cluster.leader.member.data.update(version='2.0.2', role='master')
self.ha._rewind.pg_rewind = true
self.ha._rewind.check_leader_is_not_in_recovery = true
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True)):
self.assertEqual(self.ha.run_cycle(), 'running pg_rewind from leader')
with patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=False)):
self.p.follow = true
self.assertEqual(self.ha.run_cycle(), 'starting as a secondary')
self.p.is_running = true
self.ha.follow = Mock(return_value='fake')
self.assertEqual(self.ha.run_cycle(), 'fake')
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True)) @patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
@patch.object(Bootstrap, 'create_replica', Mock(return_value=1)) @patch.object(Bootstrap, 'create_replica', Mock(return_value=1))
@@ -300,7 +320,7 @@ class TestHa(PostgresInit):
self.p.is_healthy = true self.p.is_healthy = true
self.ha.has_lock = true self.ha.has_lock = true
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('psycopg2.connect', psycopg2_connect)
def test_acquire_lock_as_master(self): def test_acquire_lock_as_master(self):
@@ -329,7 +349,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 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
@@ -354,7 +374,7 @@ class TestHa(PostgresInit):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
self.ha.has_lock = true self.ha.has_lock = true
self.p.is_leader = false self.p.is_leader = false
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')
def test_promote_without_watchdog(self): def test_promote_without_watchdog(self):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
@@ -369,12 +389,12 @@ 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 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
with patch.object(Watchdog, 'is_running', PropertyMock(return_value=True)): with patch.object(Watchdog, 'is_running', PropertyMock(return_value=True)):
self.assertEqual(self.ha.run_cycle(), 'demoting self because i do not have the lock and i was a leader') self.assertEqual(self.ha.run_cycle(), 'demoting self because I do not have the lock and I was a leader')
def test_demote_because_update_lock_failed(self): def test_demote_because_update_lock_failed(self):
self.ha.cluster.is_unlocked = false self.ha.cluster.is_unlocked = false
@@ -384,20 +404,28 @@ class TestHa(PostgresInit):
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')
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=130000))
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 and i am following a leader') self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) 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.assertEqual(self.ha.run_cycle(), 'no action. i am a secondary and i am following a leader') self.ha.cluster.config.data.update({'slots': {'l': {'database': 'a', 'plugin': 'b'}}})
self.ha.cluster.members[1].data['tags']['replicatefrom'] = 'postgresql0'
self.ha.patroni.nofailover = True
self.assertEqual(self.ha.run_cycle(), 'no action. I am a secondary (postgresql0) and following a leader ()')
del self.ha.cluster.config.data['slots']
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 ()')
del self.ha.cluster.config.data['postgresql']['use_slots']
def test_follow_in_pause(self): def test_follow_in_pause(self):
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: continue to run as master without lock') self.assertEqual(self.ha.run_cycle(), 'PAUSE: continue to run as master without lock')
self.p.is_leader = false self.p.is_leader = false
self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action') self.assertEqual(self.ha.run_cycle(), 'PAUSE: no action. I am (postgresql0)')
@patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True)) @patch.object(Rewind, 'rewind_or_reinitialize_needed_and_possible', Mock(return_value=True))
@patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True)) @patch.object(Rewind, 'can_rewind', PropertyMock(return_value=True))
@@ -508,27 +536,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 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 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 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 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 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 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 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 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()
@@ -537,28 +565,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 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 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 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 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 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 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
@@ -567,7 +595,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 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())
@@ -634,7 +662,7 @@ class TestHa(PostgresInit):
# in synchronous_mode consider itself healthy if the former leader is accessible in read-only and ahead of us # in synchronous_mode consider itself healthy if the former leader is accessible in read-only and ahead of us
with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)): with patch.object(Ha, 'is_synchronous_mode', Mock(return_value=True)):
self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.assertTrue(self.ha._is_healthiest_node(self.ha.old_cluster.members))
with patch('patroni.postgresql.Postgresql.timeline_wal_position', return_value=(1, 1, 1)): with patch('patroni.postgresql.Postgresql.last_operation', return_value=1):
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
with patch('patroni.postgresql.Postgresql.replica_cached_timeline', return_value=1): with patch('patroni.postgresql.Postgresql.replica_cached_timeline', return_value=1):
self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members)) self.assertFalse(self.ha._is_healthiest_node(self.ha.old_cluster.members))
@@ -673,7 +701,7 @@ class TestHa(PostgresInit):
def test_evaluate_scheduled_restart(self): def test_evaluate_scheduled_restart(self):
self.p.postmaster_start_time = Mock(return_value=str(postmaster_start_time)) self.p.postmaster_start_time = Mock(return_value=str(postmaster_start_time))
# restart already in progres # restart already in progress
with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)): with patch('patroni.async_executor.AsyncExecutor.busy', PropertyMock(return_value=True)):
self.assertIsNone(self.ha.evaluate_scheduled_restart()) self.assertIsNone(self.ha.evaluate_scheduled_restart())
# restart while the postmaster has been already restarted, fails # restart while the postmaster has been already restarted, fails
@@ -728,7 +756,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 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')
@@ -737,7 +765,8 @@ class TestHa(PostgresInit):
self.p.is_leader = false self.p.is_leader = false
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(), 'no action. i am a secondary and i am following a standby leader') self.assertEqual(self.ha.run_cycle(),
'no action. I am a secondary (replica) 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')
@@ -830,7 +859,8 @@ 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(), 'no action. i am a secondary and i am following a leader') self.assertEqual(self.ha.run_cycle(),
'no action. I am a secondary (postgresql0) 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):
@@ -1055,7 +1085,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 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()
@@ -1090,7 +1120,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 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):
@@ -1103,18 +1133,19 @@ 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 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 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('psycopg2.connect', psycopg2_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.ha.cluster = get_cluster_initialized_without_leader(cluster_config=config) self.ha.cluster = get_cluster_initialized_without_leader(cluster_config=config)
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 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):
@@ -1137,3 +1168,19 @@ 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('os.path.exists', Mock(return_value=True))
@patch('shutil.rmtree', Mock())
@patch('os.makedirs', Mock())
@patch('os.open', Mock())
@patch('os.fsync', Mock())
@patch('os.close', Mock())
@patch('os.rename', Mock())
@patch('patroni.postgresql.Postgresql.is_starting', Mock(return_value=False))
@patch.object(builtins, 'open', mock_open())
@patch.object(SlotsHandler, 'sync_replication_slots', Mock(return_value=['foo']))
def test_follow_copy(self):
self.ha.cluster.is_unlocked = false
self.p.is_leader = false
self.assertTrue(self.ha.run_cycle().startswith('Copying logical slots'))
+2 -5
View File
@@ -16,7 +16,8 @@ def mock_list_namespaced_config_map(*args, **kwargs):
metadata = {'resource_version': '1', 'labels': {'f': 'b'}, 'name': 'test-config', metadata = {'resource_version': '1', 'labels': {'f': 'b'}, 'name': 'test-config',
'annotations': {'initialize': '123', 'config': '{}'}} 'annotations': {'initialize': '123', 'config': '{}'}}
items = [k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))] items = [k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))]
metadata.update({'name': 'test-leader', 'annotations': {'optime': '1234', 'leader': 'p-0', 'ttl': '30s'}}) metadata.update({'name': 'test-leader',
'annotations': {'optime': '1234x', 'leader': 'p-0', 'ttl': '30s', 'slots': '{'}})
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))) items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
metadata.update({'name': 'test-failover', 'annotations': {'leader': 'p-0'}}) metadata.update({'name': 'test-failover', 'annotations': {'leader': 'p-0'}})
items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata))) items.append(k8s_client.V1ConfigMap(metadata=k8s_client.V1ObjectMeta(**metadata)))
@@ -260,10 +261,6 @@ class TestKubernetesEndpoints(BaseTestKubernetes):
self.k._kinds._object_cache['test'].metadata.annotations['leader'] = 'p-1' self.k._kinds._object_cache['test'].metadata.annotations['leader'] = 'p-1'
self.assertFalse(self.k.update_leader('123')) self.assertFalse(self.k.update_leader('123'))
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', mock_namespaced_kind, create=True)
def test_update_leader_with_restricted_access(self):
self.assertIsNotNone(self.k.update_leader('123', True))
@patch.object(k8s_client.CoreV1Api, 'read_namespaced_endpoints', create=True) @patch.object(k8s_client.CoreV1Api, 'read_namespaced_endpoints', create=True)
@patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True) @patch.object(k8s_client.CoreV1Api, 'patch_namespaced_endpoints', create=True)
def test__update_leader_with_retry(self, mock_patch, mock_read): def test__update_leader_with_retry(self, mock_patch, mock_read):
+9
View File
@@ -63,3 +63,12 @@ class TestPatroniLogger(unittest.TestCase):
self.assertRaises(Exception, logger.shutdown) self.assertRaises(Exception, logger.shutdown)
self.assertLessEqual(logger.queue_size, 2) # "Failed to close the old log handler" could be still in the queue self.assertLessEqual(logger.queue_size, 2) # "Failed to close the old log handler" could be still in the queue
self.assertEqual(logger.records_lost, 0) self.assertEqual(logger.records_lost, 0)
def test_interceptor(self):
logger = PatroniLogger()
logger.reload_config({'level': 'INFO'})
logger.start()
_LOG.info('Lock owner: ')
_LOG.info('blabla')
logger.shutdown()
self.assertEqual(logger.records_lost, 0)
+63 -36
View File
@@ -1,4 +1,4 @@
import mock # for the mock.call method, importing it without a namespace breaks python3 import datetime
import os import os
import psutil import psutil
import psycopg2 import psycopg2
@@ -8,11 +8,11 @@ import time
from mock import Mock, MagicMock, PropertyMock, patch, mock_open from mock import Mock, MagicMock, PropertyMock, patch, mock_open
from patroni.async_executor import CriticalTask from patroni.async_executor import CriticalTask
from patroni.dcs import Cluster, ClusterConfig, Member, RemoteMember, SyncState from patroni.dcs import Cluster, RemoteMember, SyncState
from patroni.exceptions import PostgresConnectionException, PatroniException from patroni.exceptions import PostgresConnectionException, PatroniException
from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE from patroni.postgresql import Postgresql, STATE_REJECT, STATE_NO_RESPONSE
from patroni.postgresql.bootstrap import Bootstrap
from patroni.postgresql.postmaster import PostmasterProcess from patroni.postgresql.postmaster import PostmasterProcess
from patroni.postgresql.slots import SlotsHandler
from patroni.utils import RetryFailedError 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
@@ -224,6 +224,7 @@ class TestPostgresql(BaseTestPostgresql):
@patch('patroni.postgresql.config.mtime', mock_mtime) @patch('patroni.postgresql.config.mtime', mock_mtime)
@patch('patroni.postgresql.config.ConfigHandler._get_pg_settings') @patch('patroni.postgresql.config.ConfigHandler._get_pg_settings')
def test_check_recovery_conf(self, mock_get_pg_settings): def test_check_recovery_conf(self, mock_get_pg_settings):
self.p.call_nowait('on_start')
mock_get_pg_settings.return_value = { mock_get_pg_settings.return_value = {
'primary_conninfo': ['primary_conninfo', 'foo=', None, 'string', 'postmaster', self.p.config._auto_conf], 'primary_conninfo': ['primary_conninfo', 'foo=', None, 'string', 'postmaster', self.p.config._auto_conf],
'recovery_min_apply_delay': ['recovery_min_apply_delay', '0', 'ms', 'integer', 'sighup', 'foo'] 'recovery_min_apply_delay': ['recovery_min_apply_delay', '0', 'ms', 'integer', 'sighup', 'foo']
@@ -259,6 +260,7 @@ class TestPostgresql(BaseTestPostgresql):
@patch.object(MockPostmaster, 'create_time', Mock(return_value=1234567), create=True) @patch.object(MockPostmaster, 'create_time', Mock(return_value=1234567), create=True)
@patch('patroni.postgresql.config.ConfigHandler._get_pg_settings') @patch('patroni.postgresql.config.ConfigHandler._get_pg_settings')
def test__read_recovery_params(self, mock_get_pg_settings): def test__read_recovery_params(self, mock_get_pg_settings):
self.p.call_nowait('on_start')
mock_get_pg_settings.return_value = {'primary_conninfo': ['primary_conninfo', '', None, 'string', mock_get_pg_settings.return_value = {'primary_conninfo': ['primary_conninfo', '', None, 'string',
'postmaster', self.p.config._postgresql_conf]} 'postmaster', self.p.config._postgresql_conf]}
self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': {'password': 'foo'}}) self.p.config.write_recovery_conf({'standby_mode': 'on', 'primary_conninfo': {'password': 'foo'}})
@@ -302,29 +304,6 @@ 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(Postgresql, 'is_running', Mock(return_value=True))
def test_sync_replication_slots(self):
self.p.start()
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
'A': 0, 'ls': 0, 'b': {'type': 'logical', 'plugin': '1'}},
'ignore_slots': [{'name': 'blabla'}]}, 1)
cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem], None, None, None)
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
self.p.slots_handler.sync_replication_slots(cluster)
self.p.slots_handler.sync_replication_slots(cluster)
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
self.p.slots_handler.sync_replication_slots(cluster)
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=True)),\
patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
cluster.members.extend([alias1, alias2])
self.p.slots_handler.sync_replication_slots(cluster)
self.assertEqual(errorlog_mock.call_count, 5)
ca = errorlog_mock.call_args_list[0][0][1]
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError)) @patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
def test__query(self): def test__query(self):
self.assertRaises(PostgresConnectionException, self.p._query, 'blabla') self.assertRaises(PostgresConnectionException, self.p._query, 'blabla')
@@ -339,14 +318,41 @@ class TestPostgresql(BaseTestPostgresql):
@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() 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.assertRaises(PostgresConnectionException, self.p.is_leader)
@patch.object(Postgresql, 'controldata', @patch.object(Postgresql, 'controldata', Mock(return_value={'Database cluster state': 'shut down',
Mock(return_value={'Database cluster state': 'shut down', 'Latest checkpoint location': 'X/678'})) 'Latest checkpoint location': '0/1ADBC18',
def test_latest_checkpoint_location(self): "Latest checkpoint's TimeLineID": '1'}))
self.assertIsNone(self.p.latest_checkpoint_location()) @patch('subprocess.Popen')
def test_latest_checkpoint_location(self, mock_popen):
mock_popen.return_value.communicate.return_value = (None, None)
self.assertEqual(self.p.latest_checkpoint_location(), '28163096')
# 9.3 and 9.4 format
mock_popen.return_value.communicate.side_effect = [
(b'rmgr: XLOG len (rec/tot): 72/ 104, tx: 0, lsn: 0/01ADBC18, prev 0/01ADBBB8, ' +
b'bkp: 0000, desc: checkpoint: redo 0/1ADBC18; tli 1; prev tli 1; fpw true; xid 0/727; oid 16386; multi' +
b' 1; offset 0; oldest xid 715 in DB 1; oldest multi 1 in DB 1; oldest running xid 0; shutdown', None),
(b'rmgr: Transaction len (rec/tot): 64/ 96, tx: 726, lsn: 0/01ADBBB8, prev 0/01ADBB70, ' +
b'bkp: 0000, desc: commit: 2021-02-26 11:19:37.900918 CET; inval msgs: catcache 11 catcache 10', None)]
self.assertEqual(self.p.latest_checkpoint_location(), '28163096')
mock_popen.return_value.communicate.side_effect = [
(b'rmgr: XLOG len (rec/tot): 72/ 104, tx: 0, lsn: 0/01ADBC18, prev 0/01ADBBB8, ' +
b'bkp: 0000, desc: checkpoint: redo 0/1ADBC18; tli 1; prev tli 1; fpw true; xid 0/727; oid 16386; multi' +
b' 1; offset 0; oldest xid 715 in DB 1; oldest multi 1 in DB 1; oldest running xid 0; shutdown', None),
(b'rmgr: XLOG len (rec/tot): 0/ 32, tx: 0, lsn: 0/01ADBBB8, prev 0/01ADBBA0, ' +
b'bkp: 0000, desc: xlog switch ', None)]
self.assertEqual(self.p.latest_checkpoint_location(), '28163000')
# 9.5+ format
mock_popen.return_value.communicate.side_effect = [
(b'rmgr: XLOG len (rec/tot): 114/ 114, tx: 0, lsn: 0/01ADBC18, prev 0/018260F8, ' +
b'desc: CHECKPOINT_SHUTDOWN redo 0/1825ED8; tli 1; prev tli 1; fpw true; xid 0:494; oid 16387; multi 1' +
b'; offset 0; oldest xid 479 in DB 1; oldest multi 1 in DB 1; oldest/newest commit timestamp xid: 0/0;' +
b' oldest running xid 0; shutdown', None),
(b'rmgr: XLOG len (rec/tot): 24/ 24, tx: 0, lsn: 0/018260F8, prev 0/01826080, ' +
b'desc: SWITCH ', None)]
self.assertEqual(self.p.latest_checkpoint_location(), '25321720')
def test_reload(self): def test_reload(self):
self.assertTrue(self.p.reload()) self.assertTrue(self.p.reload())
@@ -498,7 +504,8 @@ class TestPostgresql(BaseTestPostgresql):
parameters = self._PARAMETERS.copy() parameters = self._PARAMETERS.copy()
parameters.pop('f.oo') parameters.pop('f.oo')
parameters['wal_buffers'] = '512' parameters['wal_buffers'] = '512'
config = {'pg_hba': [''], 'pg_ident': [''], 'use_unix_socket': True, 'authentication': {}, config = {'pg_hba': [''], 'pg_ident': [''], 'use_unix_socket': True, 'use_unix_socket_repl': True,
'authentication': {},
'retry_timeout': 10, 'listen': '*', 'krbsrvname': 'postgres', 'parameters': parameters} 'retry_timeout': 10, 'listen': '*', 'krbsrvname': 'postgres', 'parameters': parameters}
self.p.reload_config(config) self.p.reload_config(config)
mock_fetchone.side_effect = Exception mock_fetchone.side_effect = Exception
@@ -514,6 +521,14 @@ class TestPostgresql(BaseTestPostgresql):
self.p.reload_config(config) self.p.reload_config(config)
self.p.config.resolve_connection_addresses() self.p.config.resolve_connection_addresses()
def test_resolve_connection_addresses(self):
self.p.config._config['use_unix_socket'] = self.p.config._config['use_unix_socket_repl'] = True
self.p.config.resolve_connection_addresses()
self.assertEqual(self.p.config.local_replication_address, {'host': '/tmp', 'port': '5432'})
self.p.config._server_parameters.pop('unix_socket_directories')
self.p.config.resolve_connection_addresses()
self.assertEqual(self.p.config._local_address, {'port': '5432'})
@patch.object(Postgresql, '_version_file_exists', Mock(return_value=True)) @patch.object(Postgresql, '_version_file_exists', Mock(return_value=True))
def test_get_major_version(self): def test_get_major_version(self):
with patch.object(builtins, 'open', mock_open(read_data='9.4')): with patch.object(builtins, 'open', mock_open(read_data='9.4')):
@@ -522,8 +537,9 @@ class TestPostgresql(BaseTestPostgresql):
self.assertEqual(self.p.get_major_version(), 0) self.assertEqual(self.p.get_major_version(), 0)
def test_postmaster_start_time(self): def test_postmaster_start_time(self):
with patch.object(MockCursor, "fetchone", Mock(return_value=('foo', True, '', '', '', '', False))): now = datetime.datetime.now()
self.assertEqual(self.p.postmaster_start_time(), 'foo') with patch.object(MockCursor, "fetchone", Mock(return_value=(now, True, '', '', '', '', False))):
self.assertEqual(self.p.postmaster_start_time(), now.isoformat(sep=' '))
t = Thread(target=self.p.postmaster_start_time) t = Thread(target=self.p.postmaster_start_time)
t.start() t.start()
t.join() t.join()
@@ -609,7 +625,7 @@ class TestPostgresql(BaseTestPostgresql):
def test_pick_sync_standby(self): def test_pick_sync_standby(self):
cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None, cluster = Cluster(True, None, self.leader, 0, [self.me, self.other, self.leadermem], None,
SyncState(0, self.me.name, self.leadermem.name), None) SyncState(0, self.me.name, self.leadermem.name), None, None)
mock_cursor = Mock() mock_cursor = Mock()
mock_cursor.fetchone.return_value = ('remote_apply',) mock_cursor.fetchone.return_value = ('remote_apply',)
@@ -707,6 +723,8 @@ class TestPostgresql(BaseTestPostgresql):
self.assertEqual(self.p.get_master_timeline(), 1) self.assertEqual(self.p.get_master_timeline(), 1)
@patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='replica')) @patch.object(Postgresql, 'get_postgres_role_from_data_directory', Mock(return_value='replica'))
@patch.object(Bootstrap, 'running_custom_bootstrap', PropertyMock(return_value=True))
@patch.object(Bootstrap, 'keep_existing_recovery_conf', PropertyMock(return_value=True))
def test__build_effective_configuration(self): def test__build_effective_configuration(self):
with patch.object(Postgresql, 'controldata', with patch.object(Postgresql, 'controldata',
Mock(return_value={'max_connections setting': '200', Mock(return_value={'max_connections setting': '200',
@@ -725,10 +743,19 @@ class TestPostgresql(BaseTestPostgresql):
@patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError(''))) @patch.object(Postgresql, '_query', Mock(side_effect=RetryFailedError('')))
def test_received_timeline(self): def test_received_timeline(self):
self.p.set_role('standby_leader') self.p.set_role('standby_leader')
self.p.reset_cluster_info_state() self.p.reset_cluster_info_state(None)
self.assertRaises(PostgresConnectionException, self.p.received_timeline) self.assertRaises(PostgresConnectionException, self.p.received_timeline)
def test__write_recovery_params(self): def test__write_recovery_params(self):
self.p.config._write_recovery_params(Mock(), {'pause_at_recovery_target': 'false'}) self.p.config._write_recovery_params(Mock(), {'pause_at_recovery_target': 'false'})
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90400)): with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90400)):
self.p.config._write_recovery_params(Mock(), {'recovery_target_action': 'PROMOTE'}) self.p.config._write_recovery_params(Mock(), {'recovery_target_action': 'PROMOTE'})
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
def test_set_enforce_hot_standby_feedback(self):
self.p.set_enforce_hot_standby_feedback(True)
@patch.object(Postgresql, 'major_version', PropertyMock(return_value=140000))
@patch.object(Postgresql, '_cluster_info_state_get', Mock(return_value=True))
def test_handle_parameter_change(self):
self.p.handle_parameter_change()
+36 -36
View File
@@ -3,13 +3,13 @@ import unittest
import tempfile import tempfile
import time import time
from mock import Mock, patch from mock import Mock, PropertyMock, patch
from patroni.dcs.raft import DynMemberSyncObj, KVStoreTTL, Raft, SyncObjUtility from patroni.dcs.raft import DynMemberSyncObj, KVStoreTTL, Raft, SyncObjUtility, TCPTransport, _TCPTransport
from pysyncobj import SyncObjConf, FAIL_REASON from pysyncobj import SyncObjConf, FAIL_REASON
def remove_files(prefix): def remove_files(prefix):
for f in ('journal', 'dump'): for f in ('journal', 'journal.meta', 'dump'):
f = prefix + f f = prefix + f
if os.path.isfile(f): if os.path.isfile(f):
for i in range(0, 15): for i in range(0, 15):
@@ -23,6 +23,16 @@ def remove_files(prefix):
time.sleep(1.0) time.sleep(1.0)
class TestTCPTransport(unittest.TestCase):
@patch.object(TCPTransport, '__init__', Mock())
@patch.object(TCPTransport, 'setOnUtilityMessageCallback', Mock())
@patch.object(TCPTransport, '_connectIfNecessarySingle', Mock(side_effect=Exception))
def test__connectIfNecessarySingle(self):
t = _TCPTransport(Mock(), None, [])
self.assertFalse(t._connectIfNecessarySingle(None))
@patch('pysyncobj.tcp_server.TcpServer.bind', Mock()) @patch('pysyncobj.tcp_server.TcpServer.bind', Mock())
class TestDynMemberSyncObj(unittest.TestCase): class TestDynMemberSyncObj(unittest.TestCase):
@@ -31,50 +41,38 @@ class TestDynMemberSyncObj(unittest.TestCase):
self.conf = SyncObjConf(appendEntriesUseBatch=False, dynamicMembershipChange=True, autoTick=False) self.conf = SyncObjConf(appendEntriesUseBatch=False, dynamicMembershipChange=True, autoTick=False)
self.so = DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf) self.so = DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
@patch.object(SyncObjUtility, 'sendMessage') @patch.object(SyncObjUtility, 'executeCommand')
def test_add_member(self, mock_send_message): def test_add_member(self, mock_execute_command):
mock_send_message.return_value = [{'addr': '127.0.0.1:1235'}, {'addr': '127.0.0.1:1236'}] mock_execute_command.return_value = [{'addr': '127.0.0.1:1235'}, {'addr': '127.0.0.1:1236'}]
mock_send_message.ver = 0 mock_execute_command.ver = 0
DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf) DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
self.conf.dynamicMembershipChange = False self.conf.dynamicMembershipChange = False
DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf) DynMemberSyncObj('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
def test___onUtilityMessage(self): def test_getMembers(self):
self.so._SyncObj__encryptor = Mock()
mock_conn = Mock() mock_conn = Mock()
mock_conn.sendRandKey = None
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, 'randkey')
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, ['members']) self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, ['members'])
self.so._SyncObj__transport._onIncomingMessageReceived(mock_conn, ['status'])
def test__SyncObj__doChangeCluster(self): def test__SyncObj__doChangeCluster(self):
self.so._SyncObj__doChangeCluster(['add', '127.0.0.1:1236']) self.so._SyncObj__doChangeCluster(['add', '127.0.0.1:1236'])
def test_utility(self):
utility = SyncObjUtility(['127.0.0.1:1235'], self.conf)
utility.setPartnerNode(list(utility._SyncObj__otherNodes)[0])
utility.sendMessage(['members'])
utility._onMessageReceived(0, '')
@patch.object(SyncObjConf, 'fullDumpFile', PropertyMock(return_value=None), create=True)
@patch.object(SyncObjConf, 'journalFile', PropertyMock(return_value=None), create=True)
class TestKVStoreTTL(unittest.TestCase): class TestKVStoreTTL(unittest.TestCase):
@patch.object(SyncObjConf, 'fullDumpFile', PropertyMock(return_value=None), create=True)
@patch.object(SyncObjConf, 'journalFile', PropertyMock(return_value=None), create=True)
def setUp(self): def setUp(self):
self.conf = SyncObjConf(appendEntriesUseBatch=False, appendEntriesPeriod=0.001,
raftMinTimeout=0.004, raftMaxTimeout=0.005, autoTickPeriod=0.001)
callback = Mock() callback = Mock()
callback.replicated = False callback.replicated = False
self.so = KVStoreTTL('127.0.0.1:1234', [], self.conf, on_set=callback, on_delete=callback) self.so = KVStoreTTL(None, callback, callback, self_addr='127.0.0.1:1234')
self.so.startAutoTick()
self.so.set_retry_timeout(10) self.so.set_retry_timeout(10)
@staticmethod
def destroy(so):
so.destroy()
so._SyncObj__thread.join()
def tearDown(self): def tearDown(self):
if self.so: if self.so:
self.destroy(self.so) self.so.destroy()
def test_set(self): def test_set(self):
self.assertTrue(self.so.set('foo', 'bar', prevExist=False, ttl=30)) self.assertTrue(self.so.set('foo', 'bar', prevExist=False, ttl=30))
@@ -83,7 +81,7 @@ class TestKVStoreTTL(unittest.TestCase):
self.assertTrue(self.so.retry(self.so._set, 'foo', {'value': 'buz', 'created': 1, 'updated': 1})) self.assertTrue(self.so.retry(self.so._set, 'foo', {'value': 'buz', 'created': 1, 'updated': 1}))
def test_delete(self): def test_delete(self):
self.conf.autoTickPeriod = 0.1 self.so.autoTickPeriod = 0.2
self.so.set('foo', 'bar') self.so.set('foo', 'bar')
self.so.set('fooo', 'bar') self.so.set('fooo', 'bar')
self.assertFalse(self.so.delete('foo', prevValue='buz')) self.assertFalse(self.so.delete('foo', prevValue='buz'))
@@ -111,11 +109,10 @@ class TestKVStoreTTL(unittest.TestCase):
def test_on_ready_override(self): def test_on_ready_override(self):
self.assertTrue(self.so.set('foo', 'bar')) self.assertTrue(self.so.set('foo', 'bar'))
self.destroy(self.so) self.so.destroy()
self.so = None self.so = None
self.conf.onReady = Mock() so = KVStoreTTL(Mock(), None, None, self_addr='127.0.0.1:1234',
self.conf.autoTick = False partner_addrs=['127.0.0.1:1235'], patronictl=True)
so = KVStoreTTL('127.0.0.1:1234', ['127.0.0.1:1235'], self.conf)
so.doTick(0) so.doTick(0)
so.destroy() so.destroy()
@@ -127,16 +124,20 @@ class TestRaft(unittest.TestCase):
def test_raft(self): def test_raft(self):
raft = Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'self_addr': '127.0.0.1:1234', raft = Raft({'ttl': 30, 'scope': 'test', 'name': 'pg', 'self_addr': '127.0.0.1:1234',
'retry_timeout': 10, 'data_dir': self._TMP}) 'retry_timeout': 10, 'data_dir': self._TMP})
raft.set_retry_timeout(20) raft.reload_config({'retry_timeout': 20, 'ttl': 60, 'loop_wait': 10})
raft.set_ttl(60) self.assertTrue(raft._sync_obj.set(raft.members_path + 'legacy', '{"version":"2.0.0"}'))
self.assertTrue(raft.touch_member('')) self.assertTrue(raft.touch_member(''))
self.assertTrue(raft.initialize()) self.assertTrue(raft.initialize())
self.assertTrue(raft.cancel_initialization()) self.assertTrue(raft.cancel_initialization())
self.assertTrue(raft.set_config_value('{}')) self.assertTrue(raft.set_config_value('{}'))
self.assertTrue(raft.write_sync_state('foo', 'bar')) self.assertTrue(raft.write_sync_state('foo', 'bar'))
self.assertTrue(raft.update_leader('1'))
self.assertTrue(raft.manual_failover('foo', 'bar')) self.assertTrue(raft.manual_failover('foo', 'bar'))
raft.get_cluster() raft.get_cluster()
self.assertTrue(raft._sync_obj.set(raft.status_path, '{"optime":1234567,"slots":{"ls":12345}}'))
raft.get_cluster()
self.assertTrue(raft.update_leader('1'))
self.assertTrue(raft._sync_obj.set(raft.status_path, '{'))
raft.get_cluster()
self.assertTrue(raft.delete_sync_state()) self.assertTrue(raft.delete_sync_state())
self.assertTrue(raft.delete_leader()) self.assertTrue(raft.delete_leader())
self.assertTrue(raft.set_history_value('')) self.assertTrue(raft.set_history_value(''))
@@ -145,7 +146,6 @@ class TestRaft(unittest.TestCase):
self.assertTrue(raft.take_leader()) self.assertTrue(raft.take_leader())
raft.watch(None, 0.001) raft.watch(None, 0.001)
raft._sync_obj.destroy() raft._sync_obj.destroy()
raft._sync_obj._SyncObj__thread.join()
def tearDown(self): def tearDown(self):
remove_files(os.path.join(self._TMP, '127.0.0.1:1234.')) remove_files(os.path.join(self._TMP, '127.0.0.1:1234.'))
+1 -1
View File
@@ -93,7 +93,7 @@ class TestRewind(BaseTestPostgresql):
self.r.rewind_or_reinitialize_needed_and_possible(self.leader) self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
with patch.object(Postgresql, 'is_running', Mock(return_value=True)): with patch.object(Postgresql, 'is_running', Mock(return_value=True)):
with patch.object(MockCursor, 'fetchone', Mock(side_effect=[(0, 0, 1, 1,), Exception])): with patch.object(MockCursor, 'fetchone', Mock(side_effect=[(0, 0, 1, 1, 0, 0, 0, 0, 0, None), Exception])):
self.r.rewind_or_reinitialize_needed_and_possible(self.leader) self.r.rewind_or_reinitialize_needed_and_possible(self.leader)
@patch.object(CancellableSubprocess, 'call', mock_cancellable_call) @patch.object(CancellableSubprocess, 'call', mock_cancellable_call)
+124
View File
@@ -0,0 +1,124 @@
import mock
import os
import psycopg2
import unittest
from mock import Mock, PropertyMock, patch
from patroni.dcs import Cluster, ClusterConfig, Member
from patroni.postgresql import Postgresql
from patroni.postgresql.slots import SlotsHandler, fsync_dir
from . import BaseTestPostgresql, psycopg2_connect, MockCursor
@patch('subprocess.call', Mock(return_value=0))
@patch('psycopg2.connect', psycopg2_connect)
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
class TestSlotsHandler(BaseTestPostgresql):
@patch('subprocess.call', Mock(return_value=0))
@patch('os.rename', Mock())
@patch('patroni.postgresql.CallbackExecutor', Mock())
@patch.object(Postgresql, 'get_major_version', Mock(return_value=130000))
@patch.object(Postgresql, 'is_running', Mock(return_value=True))
def setUp(self):
super(TestSlotsHandler, self).setUp()
self.s = self.p.slots_handler
self.p.start()
def test_sync_replication_slots(self):
config = ClusterConfig(1, {'slots': {'test_3': {'database': 'a', 'plugin': 'b'},
'A': 0, 'ls': 0, 'b': {'type': 'logical', 'plugin': '1'}},
'ignore_slots': [{'name': 'blabla'}]}, 1)
cluster = Cluster(True, config, self.leader, 0,
[self.me, self.other, self.leadermem], None, None, None, {'test_3': 10})
with mock.patch('patroni.postgresql.Postgresql._query', Mock(side_effect=psycopg2.OperationalError)):
self.s.sync_replication_slots(cluster, False)
self.p.set_role('standby_leader')
self.s.sync_replication_slots(cluster, False)
self.p.set_role('replica')
with patch.object(Postgresql, 'is_leader', Mock(return_value=False)):
self.s.sync_replication_slots(cluster, False)
self.p.set_role('master')
with mock.patch('patroni.postgresql.Postgresql.role', new_callable=PropertyMock(return_value='replica')):
self.s.sync_replication_slots(cluster, False)
with patch.object(SlotsHandler, 'drop_replication_slot', Mock(return_value=True)),\
patch('patroni.dcs.logger.error', new_callable=Mock()) as errorlog_mock:
alias1 = Member(0, 'test-3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
alias2 = Member(0, 'test.3', 28, {'conn_url': 'postgres://replicator:[email protected]:5436/postgres'})
cluster.members.extend([alias1, alias2])
self.s.sync_replication_slots(cluster, False)
self.assertEqual(errorlog_mock.call_count, 5)
ca = errorlog_mock.call_args_list[0][0][1]
self.assertTrue("test-3" in ca, "non matching {0}".format(ca))
self.assertTrue("test.3" in ca, "non matching {0}".format(ca))
with patch.object(Postgresql, 'major_version', PropertyMock(return_value=90618)):
self.s.sync_replication_slots(cluster, False)
def test_process_permanent_slots(self):
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}},
'ignore_slots': [{'name': 'blabla'}]}, 1)
cluster = Cluster(True, config, self.leader, 0, [self.me, self.other, self.leadermem], None, None, None, None)
self.s.sync_replication_slots(cluster, False)
with patch.object(Postgresql, '_query') as mock_query:
self.p.reset_cluster_info_state(None)
mock_query.return_value.fetchone.return_value = (
1, 0, 0, 0, 0, 0, 0, 0, 0,
[{"slot_name": "ls", "type": "logical", "datoid": 5, "plugin": "b",
"confirmed_flush_lsn": 12345, "catalog_xmin": 105}])
self.assertEqual(self.p.slots(), {'ls': 12345})
self.p.reset_cluster_info_state(None)
mock_query.return_value.fetchone.return_value = (
1, 0, 0, 0, 0, 0, 0, 0, 0,
[{"slot_name": "ls", "type": "logical", "datoid": 6, "plugin": "b",
"confirmed_flush_lsn": 12345, "catalog_xmin": 105}])
self.assertEqual(self.p.slots(), {})
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
def test__ensure_logical_slots_replica(self):
self.p.set_role('replica')
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
cluster = Cluster(True, config, self.leader, 0,
[self.me, self.other, self.leadermem], None, None, None, {'ls': 12346})
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
self.s._schedule_load_slots = False
with patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.errors.UndefinedFile)):
self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls'])
cluster.slots['ls'] = 'a'
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
self.assertEqual(self.s.sync_replication_slots(cluster, False), ['ls'])
@patch.object(MockCursor, 'execute', Mock(side_effect=psycopg2.OperationalError))
def test_copy_logical_slots(self):
self.s.copy_logical_slots(self.leader, ['foo'])
@patch.object(Postgresql, 'stop', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True))
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
def test_check_logical_slots_readiness(self):
self.s.copy_logical_slots(self.leader, ['ls'])
config = ClusterConfig(1, {'slots': {'ls': {'database': 'a', 'plugin': 'b'}}}, 1)
cluster = Cluster(True, config, self.leader, 0,
[self.me, self.other, self.leadermem], None, None, None, {'ls': 12345})
self.assertEqual(self.s.sync_replication_slots(cluster, False), [])
with patch.object(MockCursor, 'rowcount', PropertyMock(return_value=1), create=True):
self.s.check_logical_slots_readiness(cluster, False, None)
@patch.object(Postgresql, 'stop', Mock(return_value=True))
@patch.object(Postgresql, 'start', Mock(return_value=True))
@patch.object(Postgresql, 'is_leader', Mock(return_value=False))
def test_on_promote(self):
self.s.copy_logical_slots(self.leader, ['ls'])
self.s.on_promote()
@unittest.skipIf(os.name == 'nt', "Windows not supported")
@patch('os.open', Mock())
@patch('os.close', Mock())
@patch('os.fsync', Mock(side_effect=OSError))
def test_fsync_dir(self):
self.assertRaises(OSError, fsync_dir, 'foo')
+29 -10
View File
@@ -2,12 +2,13 @@ import select
import six import six
import unittest import unittest
from kazoo.client import KazooState 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 ZnodeStat from kazoo.protocol.states import KeeperState, ZnodeStat
from mock import Mock, patch from mock import Mock, patch
from patroni.dcs.zookeeper import Leader, PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError from patroni.dcs.zookeeper import Leader, PatroniKazooClient,\
PatroniSequentialThreadingHandler, ZooKeeper, ZooKeeperError
class MockKazooClient(Mock): class MockKazooClient(Mock):
@@ -30,7 +31,9 @@ class MockKazooClient(Mock):
def get(self, path, watch=None): def get(self, path, watch=None):
if not isinstance(path, six.string_types): if not isinstance(path, six.string_types):
raise TypeError("Invalid type for 'path' (string expected)") raise TypeError("Invalid type for 'path' (string expected)")
if path == '/no_node': if path == '/broken/status':
return (b'{', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0))
elif path in ('/no_node', '/legacy/status'):
raise NoNodeError raise NoNodeError
elif '/members/' in path: elif '/members/' in path:
return ( return (
@@ -45,6 +48,8 @@ class MockKazooClient(Mock):
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
elif path.endswith('/initialize'): elif path.endswith('/initialize'):
return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) return (b'foo', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
elif path.endswith('/status'):
return (b'{"optime":500,"slots":{"ls":1234567}}', ZnodeStat(0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0))
return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) return (b'', ZnodeStat(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
@staticmethod @staticmethod
@@ -124,9 +129,19 @@ class TestPatroniSequentialThreadingHandler(unittest.TestCase):
self.assertRaises(select.error, self.handler.select) self.assertRaises(select.error, self.handler.select)
class TestPatroniKazooClient(unittest.TestCase):
def test__call(self):
c = PatroniKazooClient()
with patch.object(KazooClient, '_call', Mock()):
self.assertIsNotNone(c._call(None, Mock()))
c._state = KeeperState.CONNECTING
self.assertFalse(c._call(None, Mock()))
class TestZooKeeper(unittest.TestCase): class TestZooKeeper(unittest.TestCase):
@patch('patroni.dcs.zookeeper.KazooClient', 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})
@@ -147,6 +162,10 @@ class TestZooKeeper(unittest.TestCase):
def test__inner_load_cluster(self): def test__inner_load_cluster(self):
self.zk._base_path = self.zk._base_path.replace('test', 'bla') self.zk._base_path = self.zk._base_path.replace('test', 'bla')
self.zk._inner_load_cluster() self.zk._inner_load_cluster()
self.zk._base_path = self.zk._base_path = '/broken'
self.zk._inner_load_cluster()
self.zk._base_path = self.zk._base_path = '/legacy'
self.zk._inner_load_cluster()
self.zk._base_path = self.zk._base_path = '/no_node' self.zk._base_path = self.zk._base_path = '/no_node'
self.zk._inner_load_cluster() self.zk._inner_load_cluster()
@@ -156,11 +175,11 @@ class TestZooKeeper(unittest.TestCase):
self.assertIsInstance(cluster.leader, Leader) self.assertIsInstance(cluster.leader, Leader)
self.zk.touch_member({'foo': 'foo'}) self.zk.touch_member({'foo': 'foo'})
self.zk._name = 'bar' self.zk._name = 'bar'
self.zk.optime_watcher(None) self.zk.status_watcher(None)
with patch.object(ZooKeeper, 'get_node', Mock(side_effect=Exception)): with patch.object(ZooKeeper, 'get_node', Mock(side_effect=Exception)):
self.zk.get_cluster() self.zk.get_cluster()
cluster = self.zk.get_cluster() cluster = self.zk.get_cluster()
self.assertEqual(cluster.last_leader_operation, 500) self.assertEqual(cluster.last_lsn, 500)
def test_delete_leader(self): def test_delete_leader(self):
self.assertTrue(self.zk.delete_leader()) self.assertTrue(self.zk.delete_leader())
@@ -203,10 +222,10 @@ class TestZooKeeper(unittest.TestCase):
self.zk.take_leader() self.zk.take_leader()
def test_update_leader(self): def test_update_leader(self):
self.assertTrue(self.zk.update_leader(None)) self.assertTrue(self.zk.update_leader(12345))
def test_write_leader_optime(self): def test_write_leader_optime(self):
self.zk.last_leader_operation = '0' self.zk.last_lsn = '0'
self.zk.write_leader_optime('1') self.zk.write_leader_optime('1')
with patch.object(MockKazooClient, 'create_async', Mock()): with patch.object(MockKazooClient, 'create_async', Mock()):
self.zk.write_leader_optime('1') self.zk.write_leader_optime('1')
@@ -221,7 +240,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.isSet = Mock(return_value=True)
self.zk._fetch_optime = False self.zk._fetch_status = False
self.zk.watch(None, 0) self.zk.watch(None, 0)
def test__kazoo_connect(self): def test__kazoo_connect(self):